From d7d8ec225275d37272fe34d999e9df2f7af8d08e Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Fri, 26 Jun 2026 21:14:26 -0400 Subject: [PATCH 01/13] Make the split-tree leaf content-agnostic Generalize the per-tab `SplitTree` from holding `GhosttySurfaceView` leaves to a content-agnostic `SurfaceView` base, so the tree, focus, occlusion, drag/drop, progress, and AX machinery no longer assume the terminal is the only kind of pane a split can hold. - Add `SurfaceView`: an `NSView, Identifiable` base whose abstract `content: SurfaceContent` is overridden by each concrete leaf. The leaf *is* the real content view (first responder, drag source, AX node), so there is no wrapper box. `id` and the default frame move up from `GhosttySurfaceView` into the base `init(id:)`. - Add the `SurfaceContent` enum (`case terminal(GhosttySurfaceView)` today). Kind-specific code routes through a `switch` on `content`, so adding a leaf kind breaks the build at every site that must handle it instead of silently failing an `as?` downcast. - `GhosttySurfaceView` now subclasses `SurfaceView`, overrides `content` to return `.terminal(self)`, and drops its own `id` / frame setup. - Retype `SplitTree` to `SplitTree` throughout `WorktreeTerminalState` and `TerminalSplitTreeView` (trees map, tab creation, layout capture/restore, split direction mapping, drop zones, AX panes). Terminal-only call sites unwrap via `switch leaf.content { case .terminal(let surface): ... }`. - Add `focusLeaf(_:in:)` to route leaf focus through `content` while the internal `surfaces` map and `focusSurface` stay concretely terminal. - Add the `SurfaceView.terminalForTesting` helper and update the terminal test suites to create/inspect leaves through it. --- Project.swift | 1 + .../Terminal/Models/SurfaceView.swift | 33 ++++ .../Models/WorktreeTerminalState.swift | 140 ++++++++++------- .../Views/TerminalSplitTreeView.swift | 40 +++-- .../Ghostty/GhosttySurfaceView.swift | 9 +- supacodeTests/AgentBusyStateTests.swift | 6 +- supacodeTests/SplitTreeTests.swift | 4 +- supacodeTests/SurfaceView+TestHelpers.swift | 12 ++ ...erminalManagerLayoutPersistenceTests.swift | 2 +- .../WorktreeTerminalManagerReaperTests.swift | 2 +- .../WorktreeTerminalManagerTests.swift | 146 +++++++++--------- 11 files changed, 238 insertions(+), 157 deletions(-) create mode 100644 supacode/Features/Terminal/Models/SurfaceView.swift create mode 100644 supacodeTests/SurfaceView+TestHelpers.swift diff --git a/Project.swift b/Project.swift index ff5080d5d..67e62f61d 100644 --- a/Project.swift +++ b/Project.swift @@ -82,6 +82,7 @@ let sharedTestSupportSources: [Path] = [ "supacodeTests/SettingsTestStorage.swift", "supacodeTests/ShellInvocationTestSupport.swift", "supacodeTests/SidebarConsistency.swift", + "supacodeTests/SurfaceView+TestHelpers.swift", "supacodeTests/WorktreeTestSupport.swift", "supacodeTests/WritableKeyPath+Sendable.swift", ] diff --git a/supacode/Features/Terminal/Models/SurfaceView.swift b/supacode/Features/Terminal/Models/SurfaceView.swift new file mode 100644 index 000000000..e8c8df9e8 --- /dev/null +++ b/supacode/Features/Terminal/Models/SurfaceView.swift @@ -0,0 +1,33 @@ +import AppKit + +/// Content-agnostic leaf of a tab's `SplitTree`. `GhosttySurfaceView` is the only +/// subclass today; additional surface kinds become peer subclasses. The leaf *is* +/// the real content view (first responder, drag source, AX node), so there is no +/// wrapper box and nothing to forward. +/// +/// Kind-specific code routes through `content` so adding a new leaf kind breaks +/// the build at every site that must handle it, rather than silently failing an +/// `as?` downcast. +class SurfaceView: NSView, Identifiable { + let id: UUID + + init(id: UUID) { + self.id = id + super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // Abstract base hook — every concrete leaf overrides this. + var content: SurfaceContent { + fatalError("SurfaceView.content must be overridden by a concrete subclass") + } +} + +/// The kind of surface a `SurfaceView` is, with its concretely-typed view. +/// Adding a case forces every `switch` on `content` to handle it. +enum SurfaceContent { + case terminal(GhosttySurfaceView) +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 65b4045db..1d73e0c12 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -64,7 +64,7 @@ final class WorktreeTerminalState { // Observed: any mutation re-renders `WorktreeTerminalTabsView`. Mutate only // from user-initiated structural changes; per-surface churn must stay on // `surfaceStates` / `WorktreeTabProjection` to keep agent storms cold. - private var trees: [TerminalTabID: SplitTree] = [:] + private var trees: [TerminalTabID: SplitTree] = [:] @ObservationIgnored private var surfaces: [UUID: GhosttySurfaceView] = [:] // `usesZmx` + `context` retained per surface so an unexpected zmx exit can recreate it on reattach. @ObservationIgnored private var surfaceLaunchMetadata: [UUID: SurfaceLaunchMetadata] = [:] @@ -237,7 +237,11 @@ final class WorktreeTerminalState { private func isTabBusy(_ tabId: TerminalTabID) -> Bool { guard let tree = trees[tabId] else { return false } - return tree.leaves().contains { isRunningProgressState($0.bridge.state.progressState) } + return tree.leaves().contains { leaf in + switch leaf.content { + case .terminal(let surface): isRunningProgressState(surface.bridge.state.progressState) + } + } } /// Per-row projection consumed by `SidebarItemFeature.terminalProjectionChanged`. @@ -309,7 +313,7 @@ final class WorktreeTerminalState { guard let tree = trees[tabID], let zoomed = tree.zoomed else { return } let previouslyZoomedSurface = zoomed.leftmostLeaf() updateTree(tree.settingZoomed(nil), for: tabID) - focusSurface(previouslyZoomedSurface, in: tabID) + focusLeaf(previouslyZoomedSurface, in: tabID) } func ensureInitialTab(focusing: Bool) { @@ -563,8 +567,8 @@ final class WorktreeTerminalState { bypassZmx: creation.bypassZmx ) updateShouldHideTabBar() - if creation.focusing, let surface = tree.root?.leftmostLeaf() { - focusSurface(surface, in: tabId) + if creation.focusing, let leaf = tree.root?.leftmostLeaf() { + focusLeaf(leaf, in: tabId) } onTabCreated?() return tabId @@ -673,19 +677,22 @@ final class WorktreeTerminalState { let focusedId = focusedSurfaceIdByTab[tabId] let isSelectedTab = (tabId == selectedTabId) let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) - for surface in tree.leaves() { - let activity = Self.surfaceActivity( - isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), - isSelectedTab: isSelectedTab, - windowIsVisible: lastWindowIsVisible == true, - windowIsKey: lastWindowIsKey == true, - focusedSurfaceID: focusedId, - surfaceID: surface.id - ) - surface.setOcclusion(activity.isVisible) - surface.focusDidChange(activity.isFocused) - if activity.isFocused { - surfaceToFocus = surface + for leaf in tree.leaves() { + switch leaf.content { + case .terminal(let surface): + let activity = Self.surfaceActivity( + isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), + isSelectedTab: isSelectedTab, + windowIsVisible: lastWindowIsVisible == true, + windowIsKey: lastWindowIsKey == true, + focusedSurfaceID: focusedId, + surfaceID: surface.id + ) + surface.setOcclusion(activity.isVisible) + surface.focusDidChange(activity.isFocused) + if activity.isFocused { + surfaceToFocus = surface + } } } } @@ -861,7 +868,7 @@ final class WorktreeTerminalState { context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB, surfaceID: UUID? = nil, bypassZmx: Bool = false - ) -> SplitTree { + ) -> SplitTree { if let existing = trees[tabId] { return existing } @@ -878,7 +885,7 @@ final class WorktreeTerminalState { surfaceID: surfaceID, bypassZmx: bypassZmx ) - let tree = SplitTree(view: surface) + let tree = SplitTree(view: surface) setTree(tree, for: tabId) setFocusedSurface(surface.id, for: tabId) return tree @@ -941,7 +948,7 @@ final class WorktreeTerminalState { } updateTree(tree, for: tabId) } - focusSurface(nextSurface, in: tabId) + focusLeaf(nextSurface, in: tabId) syncFocusIfNeeded() return true @@ -1198,18 +1205,21 @@ final class WorktreeTerminalState { } private func captureLayoutNode( - _ node: SplitTree.Node, + _ node: SplitTree.Node, agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] ) -> TerminalLayoutSnapshot.LayoutNode { switch node { case .leaf(let view): - return .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( - id: view.id, - workingDirectory: view.bridge.state.pwd, - agents: agentsBySurface[view.id] + switch view.content { + case .terminal(let surface): + return .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot( + id: surface.id, + workingDirectory: surface.bridge.state.pwd, + agents: agentsBySurface[surface.id] + ) ) - ) + } case .split(let split): let direction: SplitDirection = switch split.direction { @@ -1259,7 +1269,7 @@ final class WorktreeTerminalState { context: context, surfaceID: tabSnapshot.layout.firstLeaf.id, ) - let tree = SplitTree(view: surface) + let tree = SplitTree(view: surface) setTree(tree, for: tabId) setFocusedSurface(surface.id, for: tabId) @@ -1323,7 +1333,7 @@ final class WorktreeTerminalState { // Create the right child by splitting the anchor. let rightPwd = split.right.firstLeaf.workingDirectory let rightWorkingDir = rightPwd.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } - let direction: SplitTree.NewDirection = + let direction: SplitTree.NewDirection = split.direction == .horizontal ? .right : .down guard @@ -1347,7 +1357,7 @@ final class WorktreeTerminalState { private func createRestorationSplit( at anchor: GhosttySurfaceView, - direction: SplitTree.NewDirection, + direction: SplitTree.NewDirection, ratio: Double, workingDirectory: URL?, tabId: TerminalTabID, @@ -2024,8 +2034,17 @@ final class WorktreeTerminalState { return } let tree = splitTree(for: tabId) - if let surface = tree.visibleLeaves().first { - focusSurface(surface, in: tabId) + if let leaf = tree.visibleLeaves().first { + focusLeaf(leaf, in: tabId) + } + } + + /// Focuses a split-tree leaf by routing through its content kind. Terminal is + /// the only kind today; a new leaf kind adds a `case` here that the compiler + /// forces. + private func focusLeaf(_ leaf: SurfaceView, in tabId: TerminalTabID) { + switch leaf.content { + case .terminal(let surface): focusSurface(surface, in: tabId) } } @@ -2195,9 +2214,12 @@ final class WorktreeTerminalState { guard let tree = trees.removeValue(forKey: tabId) else { return } surfaceGenerationByTab.removeValue(forKey: tabId) let leafIDs = tree.leaves().map(\.id) - for surface in tree.leaves() { - surface.closeSurface() - cleanupSurfaceState(for: surface.id) + for leaf in tree.leaves() { + switch leaf.content { + case .terminal(let surface): + surface.closeSurface() + cleanupSurfaceState(for: surface.id) + } } killZmxSessions(forSurfaceIDs: leafIDs, includeRemote: true) focusedSurfaceIdByTab.removeValue(forKey: tabId) @@ -2258,21 +2280,27 @@ final class WorktreeTerminalState { let focusedID = focusedSurfaceIdByTab[tabId], let focused = leaves.first(where: { $0.id == focusedID }) { - return TerminalTabProgressDisplay.make( - progressState: focused.bridge.state.progressState, - progressValue: focused.bridge.state.progressValue - ) - } - var worst: TerminalTabProgressDisplay? - for surface in leaves { - guard - let candidate = TerminalTabProgressDisplay.make( + switch focused.content { + case .terminal(let surface): + return TerminalTabProgressDisplay.make( progressState: surface.bridge.state.progressState, progressValue: surface.bridge.state.progressValue ) - else { continue } - if worst == nil || candidate.severity > worst!.severity { - worst = candidate + } + } + var worst: TerminalTabProgressDisplay? + for leaf in leaves { + switch leaf.content { + case .terminal(let surface): + guard + let candidate = TerminalTabProgressDisplay.make( + progressState: surface.bridge.state.progressState, + progressValue: surface.bridge.state.progressValue + ) + else { continue } + if worst == nil || candidate.severity > worst!.severity { + worst = candidate + } } } return worst @@ -2317,7 +2345,7 @@ final class WorktreeTerminalState { applySurfaceActivity() } - private func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { + private func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { setTree(tree, for: tabId) syncFocusIfNeeded() } @@ -2325,7 +2353,7 @@ final class WorktreeTerminalState { /// Single mutation point for `trees[tabId]`. Recomputes and emits the per-tab /// projection so `TerminalTabFeature.State` mirrors `trees[tabId]`'s leaves /// + the tab's unread count + focus without observing worktree-wide state. - private func setTree(_ tree: SplitTree, for tabId: TerminalTabID) { + private func setTree(_ tree: SplitTree, for tabId: TerminalTabID) { trees[tabId] = tree // Zoom transitions flip the hide-single-tab-bar gate. updateShouldHideTabBar() @@ -2411,7 +2439,7 @@ final class WorktreeTerminalState { } private func mapSplitDirection(_ direction: GhosttySplitAction.NewDirection) - -> SplitTree.NewDirection + -> SplitTree.NewDirection { switch direction { case .left: @@ -2426,7 +2454,7 @@ final class WorktreeTerminalState { } private func mapFocusDirection(_ direction: GhosttySplitAction.FocusDirection) - -> SplitTree.FocusDirection + -> SplitTree.FocusDirection { switch direction { case .previous: @@ -2445,7 +2473,7 @@ final class WorktreeTerminalState { } private func mapResizeDirection(_ direction: GhosttySplitAction.ResizeDirection) - -> SplitTree.SpatialDirection + -> SplitTree.SpatialDirection { switch direction { case .left: @@ -2625,7 +2653,7 @@ final class WorktreeTerminalState { updateRunningState(for: tabId) if focusedSurfaceIdByTab[tabId] == view.id { if let nextSurface { - focusSurface(nextSurface, in: tabId) + focusLeaf(nextSurface, in: tabId) } else { focusedSurfaceIdByTab.removeValue(forKey: tabId) } @@ -2638,7 +2666,7 @@ final class WorktreeTerminalState { if focusedSurfaceIdByTab[tabId].flatMap({ surfaces[$0] }) == nil, let fallback = newTree.visibleLeaves().first { - focusSurface(fallback, in: tabId) + focusLeaf(fallback, in: tabId) } } @@ -2678,7 +2706,7 @@ final class WorktreeTerminalState { } private func mapDropZone(_ zone: TerminalSplitTreeView.DropZone) - -> SplitTree.NewDirection + -> SplitTree.NewDirection { switch zone { case .top: diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 865a82e1f..c32b65537 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -3,7 +3,7 @@ import SwiftUI import UniformTypeIdentifiers struct TerminalSplitTreeView: View { - let tree: SplitTree + let tree: SplitTree // Owns the per-surface `WorktreeSurfaceState` map; leaves resolve their // notification flag through `terminalState.surfaceStates[id]`. let terminalState: WorktreeTerminalState @@ -46,13 +46,13 @@ struct TerminalSplitTreeView: View { } enum Operation { - case resize(node: SplitTree.Node, ratio: Double) + case resize(node: SplitTree.Node, ratio: Double) case drop(payloadId: UUID, destinationId: UUID, zone: DropZone) case equalize } struct SubtreeView: View { - let node: SplitTree.Node + let node: SplitTree.Node var isRoot: Bool = false let terminalState: WorktreeTerminalState let activeSurfaceID: UUID? @@ -62,14 +62,17 @@ struct TerminalSplitTreeView: View { var body: some View { switch node { case .leaf(let leafView): - LeafView( - surfaceView: leafView, - surfaceState: terminalState.surfaceStates[leafView.id], - isSplit: !isRoot, - activeSurfaceID: activeSurfaceID, - unfocusedSplitOverlay: unfocusedSplitOverlay, - action: action - ) + switch leafView.content { + case .terminal(let surfaceView): + LeafView( + surfaceView: surfaceView, + surfaceState: terminalState.surfaceStates[leafView.id], + isSplit: !isRoot, + activeSurfaceID: activeSurfaceID, + unfocusedSplitOverlay: unfocusedSplitOverlay, + action: action + ) + } case .split(let split): let splitViewDirection: SplitView.Direction = switch split.direction { @@ -370,7 +373,7 @@ private struct SurfaceNotificationDot: View { /// Wraps the SwiftUI split tree in an AppKit view so we can expose an ordered /// list of terminal panes to assistive technologies. struct TerminalSplitTreeAXContainer: NSViewRepresentable { - let tree: SplitTree + let tree: SplitTree let terminalState: WorktreeTerminalState let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) @@ -400,11 +403,11 @@ final class TerminalSplitAXContainerView: NSView { // `rootView` on every update lets SwiftUI diff against a stable concrete view // type instead of re-walking an erased tree. private var hostingView: NSHostingView? - private var panes: [GhosttySurfaceView] = [] + private var panes: [SurfaceView] = [] private var panesLabel: String = "Terminal split: 0 panes" private var lastPaneIDs: [UUID] = [] - func update(rootView: TerminalSplitTreeView, panes: [GhosttySurfaceView]) { + func update(rootView: TerminalSplitTreeView, panes: [SurfaceView]) { if let hostingView { hostingView.rootView = rootView } else { @@ -425,9 +428,12 @@ final class TerminalSplitAXContainerView: NSView { panesLabel = "Terminal split: \(panes.count) pane" + (panes.count == 1 ? "" : "s") for (index, pane) in panes.enumerated() { - pane.setAccessibilityPaneIndex(index: index + 1, total: panes.count) - // Expose panes as direct children of this split group for predictable navigation. - pane.setAccessibilityParent(self) + switch pane.content { + case .terminal(let surface): + surface.setAccessibilityPaneIndex(index: index + 1, total: panes.count) + // Expose panes as direct children of this split group for predictable navigation. + surface.setAccessibilityParent(self) + } } if newPaneIDs != lastPaneIDs { diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 1dc22cd29..d88c1c3c5 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -8,7 +8,10 @@ import UniformTypeIdentifiers private let surfaceLogger = SupaLogger("Surface") -final class GhosttySurfaceView: NSView, Identifiable { +final class GhosttySurfaceView: SurfaceView { + + override var content: SurfaceContent { .terminal(self) } + private struct ScrollbarState { let total: UInt64 let offset: UInt64 @@ -70,7 +73,6 @@ final class GhosttySurfaceView: NSView, Identifiable { } private let runtime: GhosttyRuntime - let id: UUID let bridge: GhosttySurfaceBridge private(set) var surface: ghostty_surface_t? private var surfaceRef: GhosttyRuntime.SurfaceReference? @@ -213,7 +215,6 @@ final class GhosttySurfaceView: NSView, Identifiable { fontSize: Float32? = nil, context: ghostty_surface_context_e ) { - self.id = id self.runtime = runtime self.bridge = GhosttySurfaceBridge() self.fontSize = fontSize ?? 0 @@ -239,7 +240,7 @@ final class GhosttySurfaceView: NSView, Identifiable { } else { initialInputCString = nil } - super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) + super.init(id: id) wantsLayer = true bridge.surfaceView = self createSurface() diff --git a/supacodeTests/AgentBusyStateTests.swift b/supacodeTests/AgentBusyStateTests.swift index 2c4664926..1f52db208 100644 --- a/supacodeTests/AgentBusyStateTests.swift +++ b/supacodeTests/AgentBusyStateTests.swift @@ -74,8 +74,8 @@ struct AgentBusyStateTests { } guard - let surfaceA = state.splitTree(for: tabs[0]).root?.leftmostLeaf(), - let surfaceB = state.splitTree(for: tabs[1]).root?.leftmostLeaf() + let surfaceA = state.splitTree(for: tabs[0]).root?.leftmostLeaf().terminalForTesting, + let surfaceB = state.splitTree(for: tabs[1]).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected surfaces in both tabs") return @@ -436,7 +436,7 @@ struct AgentBusyStateTests { let state = manager.state(for: resolvedWorktree) { false } let tabId = state.createTab()! - let surface = state.splitTree(for: tabId).root!.leftmostLeaf() + let surface = state.splitTree(for: tabId).root!.leftmostLeaf().terminalForTesting return SurfaceFixture(manager: manager, presence: presence, state: state, tabId: tabId, surface: surface) } diff --git a/supacodeTests/SplitTreeTests.swift b/supacodeTests/SplitTreeTests.swift index a3f8d01ae..5bee3288b 100644 --- a/supacodeTests/SplitTreeTests.swift +++ b/supacodeTests/SplitTreeTests.swift @@ -216,14 +216,14 @@ struct SplitTreeTests { splitPreserveZoomOnNavigation: { preserveZoomOnNavigation } ) let tabId = state.createTab()! - let first = state.splitTree(for: tabId).root!.leftmostLeaf() + let first = state.splitTree(for: tabId).root!.leftmostLeaf().terminalForTesting _ = state.performSplitAction(.newSplit(direction: .right), for: first.id) let leaves = state.splitTree(for: tabId).leaves() return WorktreeFixture( state: state, tabId: tabId, first: first, - second: leaves.first { $0.id != first.id } + second: leaves.first { $0.id != first.id }?.terminalForTesting ) } diff --git a/supacodeTests/SurfaceView+TestHelpers.swift b/supacodeTests/SurfaceView+TestHelpers.swift new file mode 100644 index 000000000..4561b2dd8 --- /dev/null +++ b/supacodeTests/SurfaceView+TestHelpers.swift @@ -0,0 +1,12 @@ +@testable import supacode + +extension SurfaceView { + /// Test convenience: the terminal-backed surface for this leaf. The terminal + /// suites only ever create terminal leaves, so this force-unwraps the content + /// kind. When a new leaf kind is added, the `switch` here forces a decision. + var terminalForTesting: GhosttySurfaceView { + switch content { + case .terminal(let surface): surface + } + } +} diff --git a/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift b/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift index 1e420ec00..d9caffa38 100644 --- a/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift +++ b/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift @@ -226,7 +226,7 @@ struct LayoutPersistenceManagerTests { let worktree = makeWorktree() let state = harness.manager.state(for: worktree) guard let tabID = state.createTab(focusing: false), - let surface = state.splitTree(for: tabID).root?.leftmostLeaf() + let surface = state.splitTree(for: tabID).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return diff --git a/supacodeTests/WorktreeTerminalManagerReaperTests.swift b/supacodeTests/WorktreeTerminalManagerReaperTests.swift index 8fe1ee87f..b7534cab9 100644 --- a/supacodeTests/WorktreeTerminalManagerReaperTests.swift +++ b/supacodeTests/WorktreeTerminalManagerReaperTests.swift @@ -87,7 +87,7 @@ struct WorktreeTerminalManagerReaperTests { let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabID = state.createTab(focusing: false), - let surface = state.splitTree(for: tabID).root?.leftmostLeaf() + let surface = state.splitTree(for: tabID).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 210bfa04f..c05bc3e3c 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -156,7 +156,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and socket server") return @@ -178,7 +178,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -209,7 +209,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -237,7 +237,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -270,7 +270,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -298,7 +298,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -609,7 +609,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab") return @@ -673,8 +673,8 @@ struct WorktreeTerminalManagerTests { guard let tab1 = state.createTab(), let tab2 = state.createTab(focusing: false), - let surface1 = state.splitTree(for: tab1).root?.leftmostLeaf(), - let surface2 = state.splitTree(for: tab2).root?.leftmostLeaf() + let surface1 = state.splitTree(for: tab1).root?.leftmostLeaf().terminalForTesting, + let surface2 = state.splitTree(for: tab2).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected tabs and surfaces") return @@ -934,7 +934,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -957,7 +957,7 @@ struct WorktreeTerminalManagerTests { state.isSelected = { true } state.syncFocus(windowIsKey: true, windowIsVisible: true) guard let tabId = state.createTab(focusing: true), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -974,7 +974,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1034,7 +1034,7 @@ struct WorktreeTerminalManagerTests { return nil } #expect(surface.id == surfaceID) - return surface + return surface.terminalForTesting } @Test func cleanupSurfaceStateRemovesSurfaceStateEntry() { @@ -1042,7 +1042,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1064,7 +1064,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1110,9 +1110,9 @@ struct WorktreeTerminalManagerTests { let failedState = manager.state(for: failedRepoWorktree) let removedState = manager.state(for: removedWorktree) guard let failedTabID = failedState.createTab(), - let failedSurfaceID = failedState.splitTree(for: failedTabID).root?.leftmostLeaf().id, + let failedSurfaceID = failedState.splitTree(for: failedTabID).root?.leftmostLeaf().terminalForTesting.id, let removedTabID = removedState.createTab(), - let removedSurfaceID = removedState.splitTree(for: removedTabID).root?.leftmostLeaf().id + let removedSurfaceID = removedState.splitTree(for: removedTabID).root?.leftmostLeaf().terminalForTesting.id else { Issue.record("Expected protected and removed surfaces") return @@ -1209,7 +1209,7 @@ struct WorktreeTerminalManagerTests { // Session already gone locally: close + local kill, remote spared. await probe.setListing([]) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForKill { $0.contains(sessionID) } let remoteKills = await probe.remoteKilledSessions() @@ -1232,7 +1232,7 @@ struct WorktreeTerminalManagerTests { let sessionID = session(for: surface.id) #expect(state.performBindingAction("close_surface", onSurfaceID: surface.id)) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForRemoteKill { $0.contains(where: { $0.sessionID == sessionID }) } let remoteKills = await probe.remoteKilledSessions() @@ -1464,7 +1464,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: true), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1477,15 +1477,15 @@ struct WorktreeTerminalManagerTests { } await probe.setListing([.init(name: session(for: surfaceID), clients: 0)]) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForListCalls(atLeast: 1) await waitUntil("zmx surface replacement") { - guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf() else { return false } + guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { return false } return replacement.id == surfaceID && replacement !== surface } #expect(state.tabManager.tabs.contains(where: { $0.id == tabId })) - guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf() else { + guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a replacement surface") return } @@ -1503,7 +1503,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: true), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1514,12 +1514,12 @@ struct WorktreeTerminalManagerTests { surface.bridge.closeSurface(processAlive: true) await probe.waitForListCalls(atLeast: 1) await waitUntil("detached zmx surface replacement") { - guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf() else { return false } + guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { return false } return replacement.id == surfaceID && replacement !== surface } #expect(state.tabManager.tabs.contains(where: { $0.id == tabId })) - guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf() else { + guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a replacement surface") return } @@ -1533,7 +1533,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: true), - let initialSurface = state.splitTree(for: tabId).root?.leftmostLeaf() + let initialSurface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1544,7 +1544,7 @@ struct WorktreeTerminalManagerTests { Issue.record("Expected a split tab") return } - let target = originalLeaves[0] + let target = originalLeaves[0].terminalForTesting let sibling = originalLeaves[1] let targetID = target.id let siblingID = sibling.id @@ -1574,7 +1574,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: true), - let initialSurface = state.splitTree(for: tabId).root?.leftmostLeaf() + let initialSurface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1585,7 +1585,7 @@ struct WorktreeTerminalManagerTests { Issue.record("Expected a split tab") return } - let target = originalLeaves[0] + let target = originalLeaves[0].terminalForTesting let sibling = originalLeaves[1] let targetID = target.id let siblingID = sibling.id @@ -1615,7 +1615,7 @@ struct WorktreeTerminalManagerTests { toggled.setValue(true) } guard let tabId = state.createTab(focusing: true), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1623,10 +1623,10 @@ struct WorktreeTerminalManagerTests { let surfaceID = surface.id await probe.setListing([.init(name: session(for: surfaceID), clients: 0)]) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForListCalls(atLeast: 1) await waitUntil("zmx surface replacement") { - guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf() else { return false } + guard let replacement = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { return false } return replacement !== surface } @@ -1639,7 +1639,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1647,7 +1647,7 @@ struct WorktreeTerminalManagerTests { let surfaceID = surface.id let expectedKill = session(for: surfaceID) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForListCalls(atLeast: 1) await probe.waitForKill { $0.contains(expectedKill) } let killed = await probe.killedSessions() @@ -1665,14 +1665,14 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return } let surfaceID = surface.id - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForListCalls(atLeast: 1) await waitUntil("zmx surface tab closes") { !state.tabManager.tabs.contains(where: { $0.id == tabId }) @@ -1688,7 +1688,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1696,7 +1696,7 @@ struct WorktreeTerminalManagerTests { let surfaceID = surface.id await probe.setListing([.init(name: session(for: surfaceID), clients: nil)]) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForListCalls(atLeast: 1) await waitUntil("zmx surface tab closes") { !state.tabManager.tabs.contains(where: { $0.id == tabId }) @@ -1712,7 +1712,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1722,7 +1722,7 @@ struct WorktreeTerminalManagerTests { let expectedKill = session(for: surfaceID) #expect(state.closeSurface(id: surfaceID)) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForKill { $0.contains(expectedKill) } let killed = await probe.killedSessions() await waitUntil("explicit zmx surface tab closes") { @@ -1740,7 +1740,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let state = manager.state(for: makeWorktree()) guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1750,7 +1750,7 @@ struct WorktreeTerminalManagerTests { let expectedKill = session(for: surfaceID) #expect(state.performBindingAction("close_surface", onSurfaceID: surfaceID)) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await probe.waitForKill { $0.contains(expectedKill) } let killed = await probe.killedSessions() await waitUntil("binding-closed zmx surface tab closes") { @@ -1770,7 +1770,7 @@ struct WorktreeTerminalManagerTests { let state = manager.state(for: worktree) manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a blocking-script tab and surface") return @@ -1778,7 +1778,7 @@ struct WorktreeTerminalManagerTests { let surfaceID = surface.id await probe.setListing([.init(name: session(for: surfaceID), clients: 1)]) - surface.bridge.closeSurface(processAlive: false) + surface.terminalForTesting.bridge.closeSurface(processAlive: false) await waitUntil("bypass-zmx tab closes") { !state.tabManager.tabs.contains(where: { $0.id == tabId }) } @@ -1835,7 +1835,7 @@ struct WorktreeTerminalManagerTests { let state = manager.state(for: worktree) state.notificationsEnabled = false guard let tabId = state.createTab(focusing: false), - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1867,7 +1867,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -1894,7 +1894,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -1921,7 +1921,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -1929,7 +1929,7 @@ struct WorktreeTerminalManagerTests { // Normal flow: command finishes, then shell exits later. surface.bridge.onCommandFinished?(0) - surface.bridge.onChildExited?(0) + surface.terminalForTesting.bridge.onChildExited?(0) // First completion event should arrive. let event = await nextEvent(stream) { event in @@ -1953,7 +1953,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -1989,7 +1989,7 @@ struct WorktreeTerminalManagerTests { return } - surface.bridge.onChildExited?(0) + surface.terminalForTesting.bridge.onChildExited?(0) let event = await nextEvent(stream) { event in if case .blockingScriptCompleted = event { @@ -2010,7 +2010,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -2056,7 +2056,7 @@ struct WorktreeTerminalManagerTests { #expect(!state.tabManager.tabs.map(\.id).contains(firstTabId)) // Complete the second script — only this one should fire. - guard let surface = state.splitTree(for: secondTabId).root?.leftmostLeaf() else { + guard let surface = state.splitTree(for: secondTabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected surface for second tab") return } @@ -2132,7 +2132,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -2188,7 +2188,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected run script tab and surface") return @@ -2233,7 +2233,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -2262,7 +2262,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo first")) guard let state = manager.stateIfExists(for: worktree.id), let firstTabId = state.tabManager.selectedTabId, - let firstSurface = state.splitTree(for: firstTabId).root?.leftmostLeaf() + let firstSurface = state.splitTree(for: firstTabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected first blocking-script tab and surface") return @@ -2288,7 +2288,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking-script tab and surface") return @@ -2438,9 +2438,9 @@ struct WorktreeTerminalManagerTests { let stateB = manager.state(for: worktreeB) guard let tabA = stateA.createTab(), - let surfaceA = stateA.splitTree(for: tabA).root?.leftmostLeaf(), + let surfaceA = stateA.splitTree(for: tabA).root?.leftmostLeaf().terminalForTesting, let tabB = stateB.createTab(), - let surfaceB = stateB.splitTree(for: tabB).root?.leftmostLeaf() + let surfaceB = stateB.splitTree(for: tabB).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected tabs and surfaces") return @@ -2463,7 +2463,7 @@ struct WorktreeTerminalManagerTests { let state = manager.state(for: worktree) guard let tab = state.createTab(), - let surface = state.splitTree(for: tab).root?.leftmostLeaf() + let surface = state.splitTree(for: tab).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected tab and surface") return @@ -2493,9 +2493,9 @@ struct WorktreeTerminalManagerTests { let stateB = manager.state(for: worktreeB) guard let tabA = stateA.createTab(), - let surfaceA = stateA.splitTree(for: tabA).root?.leftmostLeaf(), + let surfaceA = stateA.splitTree(for: tabA).root?.leftmostLeaf().terminalForTesting, let tabB = stateB.createTab(), - let surfaceB = stateB.splitTree(for: tabB).root?.leftmostLeaf() + let surfaceB = stateB.splitTree(for: tabB).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected tabs and surfaces") return @@ -2532,7 +2532,7 @@ struct WorktreeTerminalManagerTests { let state = manager.state(for: worktree) guard let tab = state.createTab(), - let surface = state.splitTree(for: tab).root?.leftmostLeaf() + let surface = state.splitTree(for: tab).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected tab and surface") return @@ -3134,7 +3134,7 @@ struct WorktreeTerminalManagerTests { guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -3190,7 +3190,7 @@ struct WorktreeTerminalManagerTests { manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, - let surface = state.splitTree(for: tabId).root?.leftmostLeaf() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking-script tab and surface") return @@ -3207,14 +3207,14 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let state = manager.state(for: worktree) guard let regularTab = state.createTab(), - let regularSurface = state.splitTree(for: regularTab).root?.leftmostLeaf() + let regularSurface = state.splitTree(for: regularTab).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected regular tab and surface") return } manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) guard let blockingTab = state.tabManager.selectedTabId, - let blockingSurface = state.splitTree(for: blockingTab).root?.leftmostLeaf() + let blockingSurface = state.splitTree(for: blockingTab).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking-script tab and surface") return @@ -3319,8 +3319,8 @@ struct WorktreeTerminalManagerTests { guard let tabA = state.createTab(), let tabB = state.createTab(focusing: false), - let surfaceA = state.splitTree(for: tabA).root?.leftmostLeaf(), - let surfaceB = state.splitTree(for: tabB).root?.leftmostLeaf() + let surfaceA = state.splitTree(for: tabA).root?.leftmostLeaf().terminalForTesting, + let surfaceB = state.splitTree(for: tabB).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected two tabs and two surfaces") return @@ -3348,8 +3348,8 @@ struct WorktreeTerminalManagerTests { guard let tabA = state.createTab(), let tabB = state.createTab(focusing: false), - let surfaceA = state.splitTree(for: tabA).root?.leftmostLeaf(), - let surfaceB = state.splitTree(for: tabB).root?.leftmostLeaf() + let surfaceA = state.splitTree(for: tabA).root?.leftmostLeaf().terminalForTesting, + let surfaceB = state.splitTree(for: tabB).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected two tabs and two surfaces") return From ff49d8ca4661e8c7cac3d9dc07fdabe213c3aaa7 Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Sat, 27 Jun 2026 12:32:17 -0400 Subject: [PATCH 02/13] Lift surface focus-claim lifecycle into the SurfaceView base Move the "reclaim firstResponder when my view re-attaches to a window" machinery out of GhosttySurfaceView and into the SurfaceView base so any future leaf subclass inherits it. Behavior-preserving for the terminal. The base now owns onFocusChange/shouldClaimFocus, hasBeenInWindow, pendingFocusClaim, and the viewDidMoveToWindow reclaim logic, parameterized by two overridable points (focusResponder, claimsFocusOnFirstMount) and a subtree-aware steal-guard (surfaceAncestor(of:)). The detach-time focus-loss hook routes through an overridable surfaceDidDetachFromWindow(), which GhosttySurfaceView overrides to call focusDidChange(false). GhosttySurfaceView keeps the defaults (it is its own first responder, claims only on re-attach) so the terminal behaves identically. --- .../Terminal/Models/SurfaceView.swift | 87 +++++++++++++++++++ .../Ghostty/GhosttySurfaceView.swift | 63 ++------------ 2 files changed, 95 insertions(+), 55 deletions(-) diff --git a/supacode/Features/Terminal/Models/SurfaceView.swift b/supacode/Features/Terminal/Models/SurfaceView.swift index e8c8df9e8..88683d9f1 100644 --- a/supacode/Features/Terminal/Models/SurfaceView.swift +++ b/supacode/Features/Terminal/Models/SurfaceView.swift @@ -11,6 +11,24 @@ import AppKit class SurfaceView: NSView, Identifiable { let id: UUID + var onFocusChange: ((Bool) -> Void)? + /// Asked on re-attachment to a window: should this surface re-claim + /// firstResponder right now? SwiftUI detaches sibling surfaces during split + /// rebuilds (e.g. after closing a surface), and AppKit doesn't auto-promote + /// a re-attached view, so without this hook the recorded focus owner sits in + /// the tree without listening to keystrokes. Only consulted on RE-attachment + /// (not the first mount, unless `claimsFocusOnFirstMount`) so we never fight + /// the initial-focus path. + var shouldClaimFocus: (() -> Bool)? + /// Set the first time this view lands in a real window. The self-claim path + /// is gated on this so it only fires for the re-attachment case. + private var hasBeenInWindow = false + /// Outstanding self-claim Task from the most recent re-attach. Rapid split + /// rebuilds would otherwise queue one Task per attach; cancelling the prior + /// keeps the queue at most one deep without changing correctness (each Task + /// is already idempotent on its own guards). + private var pendingFocusClaim: Task? + init(id: UUID) { self.id = id super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600)) @@ -24,6 +42,75 @@ class SurfaceView: NSView, Identifiable { var content: SurfaceContent { fatalError("SurfaceView.content must be overridden by a concrete subclass") } + + /// The view that should become firstResponder when this surface re-claims + /// focus. Defaults to the leaf itself; a subclass whose responder is an + /// embedded descendant (e.g. a hosted NSView) overrides this. + var focusResponder: NSView { self } + + /// Whether to claim firstResponder on the FIRST mount, not just on + /// re-attachment. Default `false` is for surfaces whose initial focus is + /// driven by some other path (so we don't fight it); a surface with no other + /// initial-focus wiring overrides to `true`. + var claimsFocusOnFirstMount: Bool { false } + + /// Walks up from `responder` to the enclosing `SurfaceView`, if any. Used to + /// decide whether a re-attaching surface may steal focus: claiming from no + /// owner or from another surface (or its descendants) is safe; stealing from + /// an unrelated responder mid-rebuild would yank focus from whatever the user + /// is actively typing into. + static func surfaceAncestor(of responder: NSResponder?) -> SurfaceView? { + var view = responder as? NSView + while let current = view { + if let surface = current as? SurfaceView { return surface } + view = current.superview + } + return nil + } + + /// Called when this surface is detached from its window. Default no-op; a + /// subclass overrides to drop stale local focus state. + func surfaceDidDetachFromWindow() {} + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window == nil { + // SwiftUI can temporarily detach a surface while rebuilding split/zoom + // layout. If we keep stale local focus state, detached surfaces still + // intercept bindings. + pendingFocusClaim?.cancel() + pendingFocusClaim = nil + surfaceDidDetachFromWindow() + } else if hasBeenInWindow || claimsFocusOnFirstMount, shouldClaimFocus?() == true { + // Re-attached after a split-tree rebuild dropped us. AppKit doesn't + // auto-promote a re-attached view to firstResponder, so claim it back + // ourselves on the next runloop tick (immediate claim is unreliable + // when SwiftUI is mid-mount and `window` may still flip). + let attachedWindow = window + pendingFocusClaim?.cancel() + pendingFocusClaim = Task { @MainActor [weak self] in + guard + let self, + !Task.isCancelled, + let window = self.window, + window === attachedWindow, + self.shouldClaimFocus?() == true + else { return } + // Only reclaim from the no-owner or sibling-surface case. Stealing + // from an unrelated responder (command palette, inline rename text + // field) mid-rebuild would yank focus from whatever the user is + // actively typing into. + let responder = window.firstResponder + guard Self.surfaceAncestor(of: responder) !== self else { return } + if responder == nil || Self.surfaceAncestor(of: responder) != nil { + _ = window.makeFirstResponder(self.focusResponder) + } + } + } + if window != nil { + hasBeenInWindow = true + } + } } /// The kind of surface a `SurfaceView` is, with its concretely-typed view. diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index d88c1c3c5..0e855002c 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -132,23 +132,6 @@ final class GhosttySurfaceView: SurfaceView { } } } - var onFocusChange: ((Bool) -> Void)? - /// Asked on re-attachment to a window: should this surface re-claim - /// firstResponder right now? SwiftUI detaches sibling panes during split - /// rebuilds (e.g. after closing a surface), and AppKit doesn't auto-promote - /// a re-attached view, so without this hook the recorded focus owner sits in - /// the tree without listening to keystrokes. Only consulted on RE-attachment - /// (not the first mount) so we never fight the initial-focus path. - var shouldClaimFocus: (() -> Bool)? - /// Set the first time this view lands in a real window. The self-claim path - /// is gated on this so it only fires for the re-attachment case. - private var hasBeenInWindow = false - /// Outstanding self-claim Task from the most recent re-attach. Rapid split - /// rebuilds would otherwise queue one Task per attach; cancelling the prior - /// keeps the queue at most one deep without changing correctness (each Task - /// is already idempotent on its own guards). - private var pendingFocusClaim: Task? - private var accessibilityPaneIndexHelp: String? private static let mouseCursorMap: [ghostty_action_mouse_shape_e: NSCursor] = [ @@ -383,46 +366,16 @@ final class GhosttySurfaceView: SurfaceView { notificationObservers.removeAll() } + override func surfaceDidDetachFromWindow() { + focusDidChange(false) + // A removed surface can't post from layout(); without this the tint + // backdrop keeps its rect punched out as a stale untinted hole. + NotificationCenter.default.post(name: .ghosttySurfaceFrameDidChange, object: self) + } + override func viewDidMoveToWindow() { + // Runs the base's firstResponder reclaim (and detach hook) first. super.viewDidMoveToWindow() - if window == nil { - // SwiftUI can temporarily detach a pane while rebuilding split/zoom layout. - // If we keep the stale local focus bit, detached panes still intercept bindings. - pendingFocusClaim?.cancel() - pendingFocusClaim = nil - focusDidChange(false) - // A removed surface can't post from layout(); without this the tint - // backdrop keeps its rect punched out as a stale untinted hole. - NotificationCenter.default.post(name: .ghosttySurfaceFrameDidChange, object: self) - } else if hasBeenInWindow, shouldClaimFocus?() == true { - // Re-attached after a split-tree rebuild dropped us. AppKit doesn't - // auto-promote a re-attached view to firstResponder, so claim it back - // ourselves on the next runloop tick (immediate claim is unreliable - // when SwiftUI is mid-mount and `window` may still flip). - let attachedWindow = window - pendingFocusClaim?.cancel() - pendingFocusClaim = Task { @MainActor [weak self] in - guard - let self, - !Task.isCancelled, - let window = self.window, - window === attachedWindow, - self.shouldClaimFocus?() == true - else { return } - // Only reclaim from the no-owner or sibling-terminal case. Stealing - // from a non-terminal responder (command palette, inline rename text - // field) mid-rebuild would yank focus from whatever the user is - // actively typing into. - let responder = window.firstResponder - guard responder !== self else { return } - if responder == nil || responder is GhosttySurfaceView { - _ = window.makeFirstResponder(self) - } - } - } - if window != nil { - hasBeenInWindow = true - } updateScreenObservers() updateContentScale() notifySizeChanged() From ff0d53d53b48f00b5904376bff083cd069565ecb Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Mon, 29 Jun 2026 09:36:11 -0400 Subject: [PATCH 03/13] Rearrange split panes with an all-AppKit drag layer above SurfaceView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the split-pane drag-and-drop with an all-AppKit implementation that sits above the split-tree leaves, so concrete SurfaceViews carry no rearrange code — a future web-view leaf inherits it for free. - SurfaceDragCoordinator (@Observable, owned by WorktreeTerminalState) holds the in-flight drag and forwards a landed drop to performSplitOperation. - SurfaceDragHandleView is an NSDraggingSource: it paints the OS floating drag image, and its willBegin/endedAt callbacks toggle the coordinator's drag-in-flight state — a reliable signal (including cancel) that SwiftUI .onDrag lacks. - SurfaceDropCatcherView is a real AppKit NSView mounted in front of each pane for the pane's lifetime, registered solely for the private surface drag type. It is mounted persistently — not only during a drag — so its drag-type registration is in place before a session starts; AppKit acquires its drop-target set at session start and can miss a target that registers mid-drag. It stays click-through (hitTest) until a drag is in flight so it never intercepts normal clicks, then wins the drag even over a greedy child view (e.g. a future WKWebView) — while a content drop (a Finder file) carries a disjoint type and falls through to the surface below. Drops resolve through the tab's SplitTree + focusLeaf, so the rearrange is content-agnostic end to end. The terminal's own file/text drop is unchanged. Unit tests cover the coordinator lifecycle, payload parsing, and the tree mutation; the live AppKit drag was verified manually in the dev app. --- .../Models/SurfaceDragCoordinator.swift | 60 +++++ .../Terminal/Models/SurfaceView.swift | 7 + .../Models/WorktreeTerminalState.swift | 28 ++- .../Views/SurfaceDragHandleView.swift | 122 +++++++++++ .../Views/SurfaceDropCatcherView.swift | 115 ++++++++++ .../Views/TerminalSplitTreeView.swift | 205 +++--------------- .../SurfaceDragCoordinatorTests.swift | 87 ++++++++ .../WorktreeTerminalStateDropTests.swift | 92 ++++++++ 8 files changed, 531 insertions(+), 185 deletions(-) create mode 100644 supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift create mode 100644 supacode/Features/Terminal/Views/SurfaceDragHandleView.swift create mode 100644 supacode/Features/Terminal/Views/SurfaceDropCatcherView.swift create mode 100644 supacodeTests/SurfaceDragCoordinatorTests.swift create mode 100644 supacodeTests/WorktreeTerminalStateDropTests.swift diff --git a/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift b/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift new file mode 100644 index 000000000..5d6a017b2 --- /dev/null +++ b/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift @@ -0,0 +1,60 @@ +import AppKit +import SupacodeSettingsShared +import UniformTypeIdentifiers + +private let dragLogger = SupaLogger("SurfaceDrag") + +/// Coordinates pane-rearrange drag-and-drop for one worktree's terminal area. +/// +/// The rearrange machinery (drag handle + drop catcher) lives in a layer above +/// the split-tree leaves, so concrete `SurfaceView`s stay agnostic to it. This +/// object is the single piece of state that layer needs: +/// +/// - The drag *source* (`SurfaceDragHandleView`) flips `draggingSourceID` via +/// `beginDrag`/`endDrag` from its `NSDraggingSource` callbacks. The view layer +/// observes `isDragging` to mount a `SurfaceDropCatcherView` over every visible +/// leaf only while a drag is in flight (and tear them down after — `endDrag` +/// always fires, including on cancel, which is why this is AppKit-driven and +/// not SwiftUI `.onDrag`, which has no end signal). +/// - The catcher reports a landed drop through `completeDrop`, which forwards to +/// `onDrop` (wired by `WorktreeTerminalState` to mutate the split tree). +@MainActor +@Observable +final class SurfaceDragCoordinator { + /// The leaf currently being dragged by its handle, or `nil` when no rearrange + /// drag is in flight. + private(set) var draggingSourceID: UUID? + + /// True while a pane is being dragged — the view layer mounts catchers on this. + var isDragging: Bool { draggingSourceID != nil } + + /// Set by the owner to perform the tree mutation once a drop lands. The catcher + /// only knows ids + zone; the owner resolves the tab and rearranges. + var onDrop: ((_ payloadID: UUID, _ destinationID: UUID, _ zone: TerminalSplitTreeView.DropZone) -> Void)? + + func beginDrag(sourceID: UUID) { + dragLogger.debug("surfaceDrag willBegin id=\(sourceID)") + draggingSourceID = sourceID + } + + func endDrag() { + guard draggingSourceID != nil else { return } + dragLogger.debug("surfaceDrag ended") + draggingSourceID = nil + } + + func completeDrop(payloadID: UUID, destinationID: UUID, zone: TerminalSplitTreeView.DropZone) { + dragLogger.debug("drop payload=\(payloadID) dest=\(destinationID) zone=\(zone.rawValue)") + onDrop?(payloadID, destinationID, zone) + } + + /// Reads the dragged leaf id from a rearrange drag's pasteboard. The handle + /// writes the id as a plain string under `SurfaceView.surfaceDragType`, so this + /// read is synchronous (no lazy item-provider promise). Pure — unit-tested. + static func payloadID(from pasteboard: NSPasteboard) -> UUID? { + guard + let raw = pasteboard.string(forType: .init(SurfaceView.surfaceDragType.identifier)) + else { return nil } + return UUID(uuidString: raw) + } +} diff --git a/supacode/Features/Terminal/Models/SurfaceView.swift b/supacode/Features/Terminal/Models/SurfaceView.swift index 88683d9f1..475359942 100644 --- a/supacode/Features/Terminal/Models/SurfaceView.swift +++ b/supacode/Features/Terminal/Models/SurfaceView.swift @@ -1,4 +1,5 @@ import AppKit +import UniformTypeIdentifiers /// Content-agnostic leaf of a tab's `SplitTree`. `GhosttySurfaceView` is the only /// subclass today; additional surface kinds become peer subclasses. The leaf *is* @@ -11,6 +12,12 @@ import AppKit class SurfaceView: NSView, Identifiable { let id: UUID + /// Private pasteboard type carrying a leaf's id while a pane is dragged to be + /// re-split. Shared by the drag handle (source) and the drop catcher; the + /// rearrange machinery lives in a layer above `SurfaceView`, not in the leaf + /// itself, so concrete surfaces stay agnostic to it. + static let surfaceDragType = UTType(exportedAs: "sh.supacode.ghosttySurfaceId") + var onFocusChange: ((Bool) -> Void)? /// Asked on re-attachment to a window: should this surface re-claim /// firstResponder right now? SwiftUI detaches sibling surfaces during split diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 1d73e0c12..d35258cf4 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -66,6 +66,10 @@ final class WorktreeTerminalState { // `surfaceStates` / `WorktreeTabProjection` to keep agent storms cold. private var trees: [TerminalTabID: SplitTree] = [:] @ObservationIgnored private var surfaces: [UUID: GhosttySurfaceView] = [:] + /// Owns pane-rearrange drag state for this worktree's terminal area. The view + /// layer reads `isDragging` to mount drop catchers; `onDrop` (wired in `init`) + /// routes a landed drop back into `performSplitOperation`. + @ObservationIgnored let dragCoordinator = SurfaceDragCoordinator() // `usesZmx` + `context` retained per surface so an unexpected zmx exit can recreate it on reattach. @ObservationIgnored private var surfaceLaunchMetadata: [UUID: SurfaceLaunchMetadata] = [:] // Surfaces the user explicitly closed, so an unexpected zmx exit isn't mistaken for one and reattached. @@ -229,6 +233,11 @@ final class WorktreeTerminalState { // the steady state once tabs exist. @Shared(.settingsFile) var settingsFile self.shouldHideTabBar = settingsFile.global.hideSingleTabBar + dragCoordinator.onDrop = { [weak self] payloadID, destinationID, zone in + guard let self, let tabId = self.tabID(containing: destinationID) else { return } + self.performSplitOperation( + .drop(payloadId: payloadID, destinationId: destinationID, zone: zone), in: tabId) + } } var taskStatus: WorktreeTaskStatus { @@ -997,10 +1006,14 @@ final class WorktreeTerminalState { } case .drop(let payloadId, let destinationId, let zone): - guard let payload = surfaces[payloadId] else { return } - guard let destination = surfaces[destinationId] else { return } - if payload === destination { return } - guard let sourceNode = tree.root?.node(view: payload) else { return } + // Resolve through the tab's tree (content-agnostic), not the terminal-only + // `surfaces` map: a drop only ever rearranges leaves within this tab. + let leaves = tree.visibleLeaves() + guard let payload = leaves.first(where: { $0.id == payloadId }), + let destination = leaves.first(where: { $0.id == destinationId }), + payload !== destination, + let sourceNode = tree.root?.node(view: payload) + else { return } let treeWithoutSource = tree.removing(sourceNode) if treeWithoutSource.isEmpty { return } do { @@ -1010,7 +1023,7 @@ final class WorktreeTerminalState { direction: mapDropZone(zone) ) updateTree(newTree, for: tabId) - focusSurface(payload, in: tabId) + focusLeaf(payload, in: tabId) } catch { return } @@ -2755,5 +2768,10 @@ final class WorktreeTerminalState { surfaceStates[surfaceID] = state } + /// Test-only read of the tab's active pane id. + func focusedSurfaceIDForTesting(in tabId: TerminalTabID) -> UUID? { + focusedSurfaceIdByTab[tabId] + } + #endif } diff --git a/supacode/Features/Terminal/Views/SurfaceDragHandleView.swift b/supacode/Features/Terminal/Views/SurfaceDragHandleView.swift new file mode 100644 index 000000000..740a38f61 --- /dev/null +++ b/supacode/Features/Terminal/Views/SurfaceDragHandleView.swift @@ -0,0 +1,122 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +/// The grab strip at the top of a split pane. Dragging it starts an AppKit +/// `NSDraggingSession` carrying the leaf's id, which a `SurfaceDropCatcherView` +/// over another pane resolves into a re-split. +/// +/// This is an AppKit drag *source* (not SwiftUI `.onDrag`) for two reasons: an +/// `NSDraggingSession` paints the OS floating drag image over other apps, and +/// its `NSDraggingSource` `willBegin`/`ended` callbacks give a reliable +/// drag-in-flight signal (including cancel) that drives catcher mount/unmount — +/// SwiftUI `.onDrag` has no end signal. +struct SurfaceDragHandle: NSViewRepresentable { + let surfaceID: UUID + let coordinator: SurfaceDragCoordinator + + func makeNSView(context: Context) -> SurfaceDragHandleView { + SurfaceDragHandleView(surfaceID: surfaceID, coordinator: coordinator) + } + + func updateNSView(_ nsView: SurfaceDragHandleView, context: Context) { + nsView.coordinator = coordinator + } +} + +final class SurfaceDragHandleView: NSView, NSDraggingSource { + private let surfaceID: UUID + weak var coordinator: SurfaceDragCoordinator? + + private let ellipsis: NSImageView = { + let view = NSImageView() + view.image = NSImage(systemSymbolName: "ellipsis", accessibilityDescription: nil) + view.contentTintColor = NSColor.labelColor.withAlphaComponent(0.5) + view.isHidden = true + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var isHovering = false { + didSet { + guard isHovering != oldValue else { return } + ellipsis.isHidden = !isHovering + layer?.backgroundColor = + isHovering ? NSColor.labelColor.withAlphaComponent(0.12).cgColor : nil + } + } + + init(surfaceID: UUID, coordinator: SurfaceDragCoordinator) { + self.surfaceID = surfaceID + self.coordinator = coordinator + super.init(frame: .zero) + wantsLayer = true + addSubview(ellipsis) + NSLayoutConstraint.activate([ + ellipsis.centerXAnchor.constraint(equalTo: centerXAnchor), + ellipsis.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // MARK: Hover + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach { removeTrackingArea($0) } + addTrackingArea( + NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil)) + } + + override func mouseEntered(with event: NSEvent) { isHovering = true } + override func mouseExited(with event: NSEvent) { isHovering = false } + + override func resetCursorRects() { + addCursorRect(bounds, cursor: .openHand) + } + + // MARK: Drag source + + override func mouseDragged(with event: NSEvent) { + let item = NSPasteboardItem() + item.setString( + surfaceID.uuidString, + forType: NSPasteboard.PasteboardType(SurfaceView.surfaceDragType.identifier)) + let dragItem = NSDraggingItem(pasteboardWriter: item) + dragItem.setDraggingFrame(bounds, contents: snapshot()) + beginDraggingSession(with: [dragItem], event: event, source: self) + } + + func draggingSession( + _ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + .move + } + + func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) { + coordinator?.beginDrag(sourceID: surfaceID) + } + + func draggingSession( + _ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation + ) { + coordinator?.endDrag() + } + + /// Bitmap of the strip, used as the floating drag image (matches the snapshot + /// the prior SwiftUI `.onDrag` produced). + private func snapshot() -> NSImage? { + guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil } + cacheDisplay(in: bounds, to: rep) + let image = NSImage(size: bounds.size) + image.addRepresentation(rep) + return image + } +} diff --git a/supacode/Features/Terminal/Views/SurfaceDropCatcherView.swift b/supacode/Features/Terminal/Views/SurfaceDropCatcherView.swift new file mode 100644 index 000000000..1a099bcec --- /dev/null +++ b/supacode/Features/Terminal/Views/SurfaceDropCatcherView.swift @@ -0,0 +1,115 @@ +import AppKit +import SupacodeSettingsShared +import SwiftUI +import UniformTypeIdentifiers + +private let catcherLogger = SupaLogger("SurfaceDrag") + +/// Transparent drop target mounted over a leaf's content **only while a pane is +/// being dragged** (the view layer gates this on `SurfaceDragCoordinator +/// .isDragging`). It registers solely for `SurfaceView.surfaceDragType`, so it +/// only ever catches a rearrange drag — never a content drop (a Finder file, an +/// image), which carries a disjoint type and falls through to the surface below. +/// Being a real AppKit `NSView` placed in front of the hosted content lets it win +/// the drag even over a greedy child view (e.g. a future `WKWebView`). +struct SurfaceDropCatcher: NSViewRepresentable { + let surfaceID: UUID + let coordinator: SurfaceDragCoordinator + + func makeNSView(context: Context) -> SurfaceDropCatcherView { + SurfaceDropCatcherView(surfaceID: surfaceID, coordinator: coordinator) + } + + func updateNSView(_ nsView: SurfaceDropCatcherView, context: Context) {} +} + +final class SurfaceDropCatcherView: NSView { + private let surfaceID: UUID + private weak var coordinator: SurfaceDragCoordinator? + /// Half-pane accent highlight for the zone under the cursor. + private let indicator = NSView() + + init(surfaceID: UUID, coordinator: SurfaceDragCoordinator) { + self.surfaceID = surfaceID + self.coordinator = coordinator + super.init(frame: .zero) + wantsLayer = true + indicator.wantsLayer = true + indicator.layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.3).cgColor + indicator.isHidden = true + addSubview(indicator) + registerForDraggedTypes([NSPasteboard.PasteboardType(SurfaceView.surfaceDragType.identifier)]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + // The catcher is mounted for the pane's whole lifetime so its drag-type + // registration is in place before a drag session starts (AppKit acquires its + // drop-target set at session start; a target that registers mid-drag can be + // missed). It must therefore stay click-through until a pane drag is actually + // in flight, or it would swallow normal clicks on the surface content. + override func hitTest(_ point: NSPoint) -> NSView? { + guard coordinator?.isDragging == true else { return nil } + return super.hitTest(point) + } + + override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { + let zone = showIndicator(for: sender) + catcherLogger.debug("catcher entered leaf=\(surfaceID) zone=\(zone.rawValue)") + return .move + } + + override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { + _ = showIndicator(for: sender) + return .move + } + + override func draggingExited(_ sender: (any NSDraggingInfo)?) { + indicator.isHidden = true + } + + override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool { + true + } + + override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { + indicator.isHidden = true + guard + let coordinator, + let payloadID = SurfaceDragCoordinator.payloadID(from: sender.draggingPasteboard) + else { return false } + coordinator.completeDrop(payloadID: payloadID, destinationID: surfaceID, zone: zone(for: sender)) + return true + } + + @discardableResult + private func showIndicator(for sender: any NSDraggingInfo) -> TerminalSplitTreeView.DropZone { + let zone = zone(for: sender) + indicator.frame = Self.indicatorFrame(for: zone, in: bounds) + indicator.isHidden = false + return zone + } + + /// `draggingLocation` is window-space and AppKit views are bottom-left origin + /// (no surface overrides `isFlipped`), so flip Y into the top-left space + /// `DropZone.calculate` expects. + private func zone(for sender: any NSDraggingInfo) -> TerminalSplitTreeView.DropZone { + let point = convert(sender.draggingLocation, from: nil) + return .calculate(at: CGPoint(x: point.x, y: bounds.height - point.y), in: bounds.size) + } + + /// Half of `bounds` on the chosen edge, in bottom-left AppKit coordinates + /// (so `.top` is the upper half = higher y). + static func indicatorFrame(for zone: TerminalSplitTreeView.DropZone, in bounds: CGRect) -> CGRect { + let halfWidth = bounds.width / 2 + let halfHeight = bounds.height / 2 + switch zone { + case .top: return CGRect(x: 0, y: halfHeight, width: bounds.width, height: halfHeight) + case .bottom: return CGRect(x: 0, y: 0, width: bounds.width, height: halfHeight) + case .left: return CGRect(x: 0, y: 0, width: halfWidth, height: bounds.height) + case .right: return CGRect(x: halfWidth, y: 0, width: halfWidth, height: bounds.height) + } + } +} diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index c32b65537..1e5d7746f 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -1,6 +1,5 @@ import AppKit import SwiftUI -import UniformTypeIdentifiers struct TerminalSplitTreeView: View { let tree: SplitTree @@ -17,20 +16,6 @@ struct TerminalSplitTreeView: View { let unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (Operation) -> Void - private static let dragType = UTType(exportedAs: "sh.supacode.ghosttySurfaceId") - private static func dragProvider(for surfaceView: GhosttySurfaceView) -> NSItemProvider { - let provider = NSItemProvider() - let data = surfaceView.id.uuidString.data(using: .utf8) ?? Data() - provider.registerDataRepresentation( - forTypeIdentifier: dragType.identifier, - visibility: .all - ) { completion in - completion(data, nil) - return nil - } - return provider - } - var body: some View { if let node = tree.visibleNode { SubtreeView( @@ -70,6 +55,7 @@ struct TerminalSplitTreeView: View { isSplit: !isRoot, activeSurfaceID: activeSurfaceID, unfocusedSplitOverlay: unfocusedSplitOverlay, + dragCoordinator: terminalState.dragCoordinator, action: action ) } @@ -122,10 +108,9 @@ struct TerminalSplitTreeView: View { let isSplit: Bool let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) + let dragCoordinator: SurfaceDragCoordinator let action: (Operation) -> Void - @State private var dropState: DropState = .idle - private var isDimmed: Bool { // During initialization activeSurfaceID is nil and nothing should be // dimmed. @@ -134,140 +119,39 @@ struct TerminalSplitTreeView: View { } var body: some View { - GeometryReader { geometry in - GhosttyTerminalView(surfaceView: surfaceView) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .overlay { - if isDimmed, let fill = unfocusedSplitOverlay.fill, unfocusedSplitOverlay.opacity > 0 { - fill - .opacity(unfocusedSplitOverlay.opacity) - .allowsHitTesting(false) - } - } - .overlay(alignment: .topTrailing) { - if surfaceView.bridge.state.searchNeedle != nil { - GhosttySurfaceSearchOverlay(surfaceView: surfaceView) - } - } - .overlay(alignment: .topTrailing) { - SurfaceNotificationDotIndicator(state: surfaceState) - } - .overlay(alignment: .top) { - if isSplit { - DragHandle(surfaceView: surfaceView) - } - } - .background { - Color.clear - .contentShape(.rect) - .onDrop( - of: [TerminalSplitTreeView.dragType], - delegate: SplitDropDelegate( - dropState: $dropState, - viewSize: geometry.size, - destinationId: surfaceView.id, - action: action - )) - } - .overlay { - if case .dropping(let zone) = dropState { - DropOverlayView(zone: zone, size: geometry.size) - .allowsHitTesting(false) - } - } - } - } - - } - - struct DragHandle: View { - let surfaceView: GhosttySurfaceView - private let handleHeight: CGFloat = 10 - @State private var isHovering = false - - var body: some View { - Rectangle() - .fill(Color.primary.opacity(isHovering ? 0.12 : 0)) - .frame(maxWidth: .infinity) - .frame(height: handleHeight) + GhosttyTerminalView(surfaceView: surfaceView) + .frame(maxWidth: .infinity, maxHeight: .infinity) .overlay { - if isHovering { - Image(systemName: "ellipsis") - .font(.system(.callout, weight: .semibold)) - .foregroundStyle(.primary.opacity(0.5)) - .accessibilityHidden(true) + if isDimmed, let fill = unfocusedSplitOverlay.fill, unfocusedSplitOverlay.opacity > 0 { + fill + .opacity(unfocusedSplitOverlay.opacity) + .allowsHitTesting(false) } } - .contentShape(.rect) - .onHover { hovering in - guard hovering != isHovering else { return } - isHovering = hovering - if hovering { - NSCursor.openHand.push() - } else { - NSCursor.pop() + .overlay(alignment: .topTrailing) { + if surfaceView.bridge.state.searchNeedle != nil { + GhosttySurfaceSearchOverlay(surfaceView: surfaceView) } } - .onDisappear { - if isHovering { - isHovering = false - NSCursor.pop() + .overlay(alignment: .topTrailing) { + SurfaceNotificationDotIndicator(state: surfaceState) + } + .overlay(alignment: .top) { + if isSplit { + SurfaceDragHandle(surfaceID: surfaceView.id, coordinator: dragCoordinator) + .frame(height: handleHeight) } } - .onDrag { - TerminalSplitTreeView.dragProvider(for: surfaceView) + .overlay { + // Mounted for the pane's lifetime (not only during a drag) so the catcher's + // drag-type registration is ready before a drag session starts; AppKit can + // miss a drop target that registers mid-drag. `SurfaceDropCatcherView.hitTest` + // keeps it click-through until a drag is in flight. + SurfaceDropCatcher(surfaceID: surfaceView.id, coordinator: dragCoordinator) } } - } - - enum DropState: Equatable { - case idle - case dropping(DropZone) - } - struct SplitDropDelegate: DropDelegate { - @Binding var dropState: DropState - let viewSize: CGSize - let destinationId: UUID - let action: (Operation) -> Void - - func validateDrop(info: DropInfo) -> Bool { - info.hasItemsConforming(to: [TerminalSplitTreeView.dragType]) - } - - func dropEntered(info: DropInfo) { - dropState = .dropping(.calculate(at: info.location, in: viewSize)) - } - - func dropUpdated(info: DropInfo) -> DropProposal? { - guard case .dropping = dropState else { return DropProposal(operation: .forbidden) } - dropState = .dropping(.calculate(at: info.location, in: viewSize)) - return DropProposal(operation: .move) - } - - func dropExited(info: DropInfo) { - dropState = .idle - } - - func performDrop(info: DropInfo) -> Bool { - let zone = DropZone.calculate(at: info.location, in: viewSize) - dropState = .idle - - let providers = info.itemProviders(for: [TerminalSplitTreeView.dragType]) - guard let provider = providers.first else { return false } - provider.loadDataRepresentation( - forTypeIdentifier: TerminalSplitTreeView.dragType.identifier - ) { data, _ in - guard let data, - let raw = String(data: data, encoding: .utf8), - let payloadId = UUID(uuidString: raw) - else { return } - Task { @MainActor in - action(.drop(payloadId: payloadId, destinationId: destinationId, zone: zone)) - } - } - return true - } + private let handleHeight: CGFloat = 10 } enum DropZone: String, Equatable { @@ -294,45 +178,6 @@ struct TerminalSplitTreeView: View { } } - struct DropOverlayView: View { - let zone: DropZone - let size: CGSize - - var body: some View { - let overlayColor = Color.accentColor.opacity(0.3) - - switch zone { - case .top: - VStack(spacing: 0) { - Rectangle() - .fill(overlayColor) - .frame(height: size.height / 2) - Spacer() - } - case .bottom: - VStack(spacing: 0) { - Spacer() - Rectangle() - .fill(overlayColor) - .frame(height: size.height / 2) - } - case .left: - HStack(spacing: 0) { - Rectangle() - .fill(overlayColor) - .frame(width: size.width / 2) - Spacer() - } - case .right: - HStack(spacing: 0) { - Spacer() - Rectangle() - .fill(overlayColor) - .frame(width: size.width / 2) - } - } - } - } } // MARK: - Surface notification indicator. diff --git a/supacodeTests/SurfaceDragCoordinatorTests.swift b/supacodeTests/SurfaceDragCoordinatorTests.swift new file mode 100644 index 000000000..f2014f726 --- /dev/null +++ b/supacodeTests/SurfaceDragCoordinatorTests.swift @@ -0,0 +1,87 @@ +import AppKit +import Foundation +import Testing +import UniformTypeIdentifiers + +@testable import supacode + +@MainActor +struct SurfaceDragCoordinatorTests { + @Test func beginDragSetsInFlightState() { + let coordinator = SurfaceDragCoordinator() + let source = UUID() + + #expect(!coordinator.isDragging) + coordinator.beginDrag(sourceID: source) + #expect(coordinator.isDragging) + #expect(coordinator.draggingSourceID == source) + } + + @Test func endDragClearsState() { + let coordinator = SurfaceDragCoordinator() + coordinator.beginDrag(sourceID: UUID()) + + coordinator.endDrag() + #expect(!coordinator.isDragging) + #expect(coordinator.draggingSourceID == nil) + } + + // A cancelled drag (released over nothing / Escape) ends without a drop. The + // AppKit source still fires `endedAt`, so `endDrag` must reset cleanly even + // when no `completeDrop` happened — otherwise catchers would leak. + @Test func endDragWithoutDropStillClears() { + let coordinator = SurfaceDragCoordinator() + var dropped = false + coordinator.onDrop = { _, _, _ in dropped = true } + + coordinator.beginDrag(sourceID: UUID()) + coordinator.endDrag() + + #expect(!coordinator.isDragging) + #expect(!dropped) + } + + @Test func completeDropForwardsToOnDrop() { + let coordinator = SurfaceDragCoordinator() + let payload = UUID() + let destination = UUID() + var receivedPayload: UUID? + var receivedDestination: UUID? + var receivedZone: TerminalSplitTreeView.DropZone? + coordinator.onDrop = { payloadID, destinationID, zone in + receivedPayload = payloadID + receivedDestination = destinationID + receivedZone = zone + } + + coordinator.completeDrop(payloadID: payload, destinationID: destination, zone: .right) + + #expect(receivedPayload == payload) + #expect(receivedDestination == destination) + #expect(receivedZone == .right) + } + + @Test func payloadIDParsesSurfaceDragType() { + let pasteboard = NSPasteboard.withUniqueName() + let id = UUID() + pasteboard.clearContents() + pasteboard.setString( + id.uuidString, + forType: NSPasteboard.PasteboardType(SurfaceView.surfaceDragType.identifier)) + + #expect(SurfaceDragCoordinator.payloadID(from: pasteboard) == id) + } + + @Test func payloadIDIsNilForMissingOrGarbage() { + let empty = NSPasteboard.withUniqueName() + empty.clearContents() + #expect(SurfaceDragCoordinator.payloadID(from: empty) == nil) + + let garbage = NSPasteboard.withUniqueName() + garbage.clearContents() + garbage.setString( + "not-a-uuid", + forType: NSPasteboard.PasteboardType(SurfaceView.surfaceDragType.identifier)) + #expect(SurfaceDragCoordinator.payloadID(from: garbage) == nil) + } +} diff --git a/supacodeTests/WorktreeTerminalStateDropTests.swift b/supacodeTests/WorktreeTerminalStateDropTests.swift new file mode 100644 index 000000000..24e1711ba --- /dev/null +++ b/supacodeTests/WorktreeTerminalStateDropTests.swift @@ -0,0 +1,92 @@ +import Foundation +import GhosttyKit +import Testing + +@testable import supacode + +@MainActor +struct WorktreeTerminalStateDropTests { + private struct SplitTab { + let state: WorktreeTerminalState + let tabId: TerminalTabID + let paneA: GhosttySurfaceView + let paneB: GhosttySurfaceView + } + + /// Dropping a pane onto a sibling rearranges the tree by id (content-agnostic + /// resolution through `visibleLeaves`, not the terminal-only `surfaces` map) + /// and focuses the moved pane. + @Test func dropRearrangesTreeAndFocusesPayload() { + let tab = makeSplitTab() + + // Initial `.newSplit(.right)` puts B to the right of A. + #expect(tab.state.splitTree(for: tab.tabId).root?.leftmostLeaf().id == tab.paneA.id) + + // Drop B onto A's left → B becomes the leftmost pane. + tab.state.performSplitOperation( + .drop(payloadId: tab.paneB.id, destinationId: tab.paneA.id, zone: .left), in: tab.tabId) + + let leaves = tab.state.splitTree(for: tab.tabId).visibleLeaves() + #expect(leaves.count == 2) + #expect(tab.state.splitTree(for: tab.tabId).root?.leftmostLeaf().id == tab.paneB.id) + #expect(tab.state.focusedSurfaceIDForTesting(in: tab.tabId) == tab.paneB.id) + } + + @Test func dropOntoSelfIsNoOp() { + let tab = makeSplitTab() + let before = tab.state.splitTree(for: tab.tabId).root?.leftmostLeaf().id + + tab.state.performSplitOperation( + .drop(payloadId: tab.paneA.id, destinationId: tab.paneA.id, zone: .left), in: tab.tabId) + + #expect(tab.state.splitTree(for: tab.tabId).root?.leftmostLeaf().id == before) + #expect(tab.state.splitTree(for: tab.tabId).visibleLeaves().count == 2) + } + + @Test func dropWithUnknownPayloadIsNoOp() { + let tab = makeSplitTab() + let before = tab.state.splitTree(for: tab.tabId).root?.leftmostLeaf().id + + tab.state.performSplitOperation( + .drop(payloadId: UUID(), destinationId: tab.paneA.id, zone: .left), in: tab.tabId) + + #expect(tab.state.splitTree(for: tab.tabId).root?.leftmostLeaf().id == before) + #expect(tab.state.splitTree(for: tab.tabId).visibleLeaves().count == 2) + } + + // MARK: - Helpers + + /// A worktree state with one tab split into two terminal panes (A left, B right). + private func makeSplitTab() -> SplitTab { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let state = manager.state(for: makeWorktree()) + state.ensureInitialTab(focusing: true) + + guard let tabId = state.tabManager.selectedTabId, + let paneA = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting + else { + fatalError("Expected an initial tab with one terminal pane") + } + + _ = state.performSplitAction(.newSplit(direction: .right), for: paneA.id) + + guard + let paneB = state.splitTree(for: tabId).visibleLeaves() + .first(where: { $0.id != paneA.id })?.terminalForTesting + else { + fatalError("Expected a second pane after the split") + } + + return SplitTab(state: state, tabId: tabId, paneA: paneA, paneB: paneB) + } + + private func makeWorktree(id: String = "/tmp/repo/wt-1") -> Worktree { + Worktree( + id: WorktreeID(id), + name: URL(fileURLWithPath: id).lastPathComponent, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + ) + } +} From 885f1e3afea2e0e39635dbcdc61fa202735cc203 Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Fri, 10 Jul 2026 19:30:02 -0400 Subject: [PATCH 04/13] Collapse surface creation variants into kind-typed SurfaceSpec payloads createTab/createTabWithInput fuse into one createTab carrying a SurfaceSpec; splitSurface's raw input moves into the same payload. Which kind of surface to create is now data on the neutral creation verbs instead of verb spelling, so a future kind adds a spec case rather than new commands. --- .../Clients/Terminal/TerminalClient.swift | 5 ++- .../Features/App/Reducer/AppFeature.swift | 13 ++++---- .../WorktreeTerminalManager.swift | 32 ++++++++++++------- .../Terminal/Models/SurfaceSpec.swift | 26 +++++++++++++++ supacodeTests/AppFeatureDeeplinkTests.swift | 8 ++--- .../AppFeatureOpenWorktreeTests.swift | 4 +-- .../AppFeatureTerminalSetupScriptTests.swift | 4 +-- 7 files changed, 63 insertions(+), 29 deletions(-) create mode 100644 supacode/Features/Terminal/Models/SurfaceSpec.swift diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index f6b379e03..67c59305c 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -33,8 +33,7 @@ struct TerminalClient { ) -> Void enum Command: Equatable { - case createTab(Worktree, runSetupScriptIfNew: Bool, id: UUID? = nil) - case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, id: UUID? = nil) + case createTab(Worktree, spec: SurfaceSpec, id: UUID? = nil) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) case stopRunScript(Worktree) case stopScript(Worktree, definitionID: UUID) @@ -54,7 +53,7 @@ struct TerminalClient { case focusSurface(Worktree, tabID: TerminalTabID, surfaceID: UUID, input: String? = nil) case splitSurface( Worktree, tabID: TerminalTabID, surfaceID: UUID, direction: SplitDirection, - input: String?, id: UUID? = nil) + spec: SurfaceSpec, id: UUID? = nil) case destroyTab(Worktree, tabID: TerminalTabID) case destroySurface(Worktree, tabID: TerminalTabID, surfaceID: UUID) case beginTabRename(Worktree, tabID: TerminalTabID? = nil) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 019bc7e8e..cec37c21a 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -695,7 +695,7 @@ struct AppFeature { let shouldRunSetupScript = state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in - await terminalClient.send(.createTab(worktree, runSetupScriptIfNew: shouldRunSetupScript)) + await terminalClient.send(.createTab(worktree, spec: .terminal(runSetupScriptIfNew: shouldRunSetupScript))) } case .selectTerminalTabAtIndex(let tabNumber): @@ -1622,10 +1622,9 @@ struct AppFeature { state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in await terminalClient.send( - .createTabWithInput( + .createTab( worktree, - input: "$EDITOR", - runSetupScriptIfNew: shouldRunSetupScript + spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: shouldRunSetupScript) ) ) } @@ -2000,7 +1999,7 @@ struct AppFeature { } guard let input, !input.isEmpty else { let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in - .createTab(worktree, runSetupScriptIfNew: true, id: id) + .createTab(worktree, spec: .terminal(runSetupScriptIfNew: true), id: id) } return awaitingCompletion( effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) }, @@ -2012,7 +2011,7 @@ struct AppFeature { message: .command(input), action: action, state: &state) } let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in - .createTabWithInput(worktree, input: input, runSetupScriptIfNew: false, id: id) + .createTab(worktree, spec: .terminal(input: input), id: id) } return awaitingCompletion( effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) }, @@ -2079,7 +2078,7 @@ struct AppFeature { let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in .splitSurface( worktree, tabID: TerminalTabID(rawValue: tabID), surfaceID: surfaceID, - direction: direction, input: input, id: id) + direction: direction, spec: .terminal(input: input), id: id) } return awaitingCompletion( effect, match: id.map { .surfaceSplit(worktreeID: worktreeID, surfaceID: $0) }, diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index ce211a9db..2b1d62b93 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -278,11 +278,17 @@ final class WorktreeTerminalManager { // swiftlint:disable:next cyclomatic_complexity private func handleTabCommand(_ command: TerminalClient.Command) -> Bool { switch command { - case .createTab(let worktree, let runSetupScriptIfNew, let id): - Task { createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, tabID: id) } - case .createTabWithInput(let worktree, let input, let runSetupScriptIfNew, let id): - Task { - createTabAsync(in: worktree, runSetupScriptIfNew: runSetupScriptIfNew, initialInput: input, tabID: id) + case .createTab(let worktree, let spec, let id): + switch spec { + case .terminal(let terminalSpec): + Task { + createTabAsync( + in: worktree, + runSetupScriptIfNew: terminalSpec.runSetupScriptIfNew, + initialInput: terminalSpec.input, + tabID: id + ) + } } case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): let state = state(for: worktree) { runSetupScriptIfNew } @@ -315,16 +321,20 @@ final class WorktreeTerminalManager { if let input, !input.isEmpty { terminal.focusAndInsertText(input + "\r") } - case .splitSurface(let worktree, let tabID, let surfaceID, let direction, let input, let id): + case .splitSurface(let worktree, let tabID, let surfaceID, let direction, let spec, let id): let terminal = state(for: worktree) terminal.selectTab(tabID) let ghosttyDirection: GhosttySplitAction.NewDirection = direction == .vertical ? .down : .right - let resolvedInput = BlockingScriptRunner.makeCommandInput(script: input ?? "") + let initialInput: String? + switch spec { + case .terminal(let terminalSpec): + initialInput = BlockingScriptRunner.makeCommandInput(script: terminalSpec.input ?? "") + } let splitSucceeded = terminal.performSplitAction( .newSplit(direction: ghosttyDirection), for: surfaceID, newSurfaceID: id, - initialInput: resolvedInput + initialInput: initialInput ) guard splitSucceeded else { terminalLogger.warning("splitSurface: failed for surface \(surfaceID) in worktree \(worktree.id).") @@ -373,7 +383,7 @@ final class WorktreeTerminalManager { state(for: worktree).navigateSearchOnFocusedSurface(.previous) case .endSearch(let worktree): state(for: worktree).performBindingActionOnFocusedSurface("end_search") - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + case .createTab, .ensureInitialTab, .stopRunScript, .stopScript, .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .performBindingActionOnSurface, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .setImagePasteAgents, .prune, .setNotificationsEnabled, .setSelectedWorktreeID, @@ -391,7 +401,7 @@ final class WorktreeTerminalManager { state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) case .setImagePasteAgents(let surfaceID, let agents): setImagePasteAgents(agents, onSurfaceID: surfaceID) - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + case .createTab, .ensureInitialTab, .stopRunScript, .stopScript, .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, @@ -429,7 +439,7 @@ final class WorktreeTerminalManager { // event fires; refresh here or the window keeps the previous tint. refreshFocusedSurfaceBackground() terminalLogger.info("Selected worktree \(id?.rawValue ?? "nil")") - case .createTab, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, + case .createTab, .ensureInitialTab, .stopRunScript, .stopScript, .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .performBindingActionOnSurface, .setImagePasteAgents, .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, .focusSurface, diff --git a/supacode/Features/Terminal/Models/SurfaceSpec.swift b/supacode/Features/Terminal/Models/SurfaceSpec.swift new file mode 100644 index 000000000..388dcdf17 --- /dev/null +++ b/supacode/Features/Terminal/Models/SurfaceSpec.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Value-level "what to create" descriptor for a new surface, distinct from the +/// view-level `SurfaceContent`. Creation verbs (`createTab`, `splitSurface`) +/// carry one of these so "which kind" is data instead of verb spelling; adding +/// a surface kind adds a case here and the compiler forces every dispatch +/// switch to handle it. +enum SurfaceSpec: Equatable, Sendable { + case terminal(TerminalSurfaceSpec) + + /// Convenience for the common call-site shape. + static func terminal(input: String? = nil, runSetupScriptIfNew: Bool = false) -> SurfaceSpec { + .terminal(TerminalSurfaceSpec(input: input, runSetupScriptIfNew: runSetupScriptIfNew)) + } +} + +/// Terminal-kind creation payload. +struct TerminalSurfaceSpec: Equatable, Sendable { + /// Command text injected into the new surface's shell once it attaches + /// (e.g. `$EDITOR`, a deeplink-provided command). + var input: String? + /// Run the repository setup script when this creation is the worktree's + /// first. Only meaningful for tab creation; splits happen inside an + /// already-initialized worktree and ignore it. + var runSetupScriptIfNew: Bool = false +} diff --git a/supacodeTests/AppFeatureDeeplinkTests.swift b/supacodeTests/AppFeatureDeeplinkTests.swift index fb077500e..0e9ab99be 100644 --- a/supacodeTests/AppFeatureDeeplinkTests.swift +++ b/supacodeTests/AppFeatureDeeplinkTests.swift @@ -1267,7 +1267,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo hello", runSetupScriptIfNew: false, id: nil) + .createTab(worktree, spec: .terminal(input: "echo hello"), id: nil) ) ) } @@ -1302,7 +1302,7 @@ struct AppFeatureDeeplinkTests { } #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo hello", runSetupScriptIfNew: false, id: nil) + .createTab(worktree, spec: .terminal(input: "echo hello"), id: nil) ) ) await store.finish() @@ -2081,7 +2081,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo test", runSetupScriptIfNew: false, id: nil) + .createTab(worktree, spec: .terminal(input: "echo test"), id: nil) ) ) } @@ -2506,7 +2506,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo test", runSetupScriptIfNew: false, id: nil) + .createTab(worktree, spec: .terminal(input: "echo test"), id: nil) ) ) } diff --git a/supacodeTests/AppFeatureOpenWorktreeTests.swift b/supacodeTests/AppFeatureOpenWorktreeTests.swift index ace32cc30..988b7f745 100644 --- a/supacodeTests/AppFeatureOpenWorktreeTests.swift +++ b/supacodeTests/AppFeatureOpenWorktreeTests.swift @@ -36,7 +36,7 @@ struct AppFeatureOpenWorktreeTests { #expect(context.openedActions.value.isEmpty) #expect( context.terminalCommands.value == [ - .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: false) + .createTab(context.worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: false)) ] ) await store.finish() @@ -49,7 +49,7 @@ struct AppFeatureOpenWorktreeTests { await store.receive(\.repositories.delegate.openWorktreeInApp) #expect( context.terminalCommands.value == [ - .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: true) + .createTab(context.worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: true)) ] ) await store.finish() diff --git a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift index c6b6fbaf9..1bb7d4360 100644 --- a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift +++ b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift @@ -38,7 +38,7 @@ struct AppFeatureTerminalSetupScriptTests { $0.repositories.applyPostReduceCacheRecomputes([.sidebarStructure, .selectedWorktreeSlice]) } await store.finish() - #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: true, id: nil)]) + #expect(sent.value == [.createTab(worktree, spec: .terminal(runSetupScriptIfNew: true), id: nil)]) } @Test(.dependencies) func newTerminalWithoutSetupScriptDoesNotConsume() async { @@ -64,7 +64,7 @@ struct AppFeatureTerminalSetupScriptTests { await store.send(.newTerminal) await store.finish() - #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: false, id: nil)]) + #expect(sent.value == [.createTab(worktree, spec: .terminal(runSetupScriptIfNew: false), id: nil)]) } @Test(.dependencies) func tabCreatedDoesNotConsumeSetupScript() async { From fd58c0d6476a4d47b05808b23f9ebce26d34820d Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Fri, 10 Jul 2026 19:39:24 -0400 Subject: [PATCH 05/13] Scope terminal-only commands and events under one kind arm TerminalClient.Command keeps only neutral lifecycle verbs; scripts, binding actions, and scrollback search move into TerminalSurfaceCommand behind a single Command.terminal(Worktree, _) arm the manager dispatches to a terminal-kind handler. Event mirrors it: taskStatusChanged, blockingScriptCompleted, setupScriptConsumed, agentHookEventReceived, and the has-any-surface aggregate ride Event.terminal(_). The exhaustive switch on the wrapper arm is the registration point for a future kind. On the reducer side, AppFeature handles the wrapper through a single .terminalEvent(.terminal(_)) arm that delegates to handleTerminalSurfaceEvent; neutral lifecycle / projection arms keep their own arms. A future kind adds a sibling handler behind its own wrapper arm. --- .../Clients/Terminal/TerminalClient.swift | 27 +-- .../App/Models/WorktreeMenuSnapshot.swift | 6 +- .../Features/App/Reducer/AppFeature.swift | 103 +++++----- .../WorktreeTerminalManager.swift | 111 +++++------ .../Models/TerminalSurfaceCommand.swift | 36 ++++ supacodeTests/AgentBusyStateTests.swift | 4 +- supacodeTests/AgentPresence+TestHelpers.swift | 2 +- .../AppFeatureCommandPaletteTests.swift | 8 +- supacodeTests/AppFeatureDeeplinkTests.swift | 18 +- supacodeTests/AppFeatureRunScriptTests.swift | 24 +-- .../AppFeatureSplitTerminalTests.swift | 2 +- .../AppFeatureTerminalSetupScriptTests.swift | 4 +- .../WorktreeTerminalManagerTests.swift | 176 ++++++++++-------- 13 files changed, 279 insertions(+), 242 deletions(-) create mode 100644 supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index 67c59305c..7c52ac725 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -35,19 +35,9 @@ struct TerminalClient { enum Command: Equatable { case createTab(Worktree, spec: SurfaceSpec, id: UUID? = nil) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) - case stopRunScript(Worktree) - case stopScript(Worktree, definitionID: UUID) - case runBlockingScript(Worktree, kind: BlockingScriptKind, script: String) case closeFocusedTab(Worktree) case closeFocusedSurface(Worktree) - case performBindingAction(Worktree, action: String) - case performBindingActionOnSurface(Worktree, surfaceID: UUID, action: String) case setImagePasteAgents(surfaceID: UUID, agents: Set) - case startSearch(Worktree) - case searchSelection(Worktree) - case navigateSearchNext(Worktree) - case navigateSearchPrevious(Worktree) - case endSearch(Worktree) case selectTab(Worktree, tabID: TerminalTabID) case selectTabAtIndex(Worktree, index: Int) case focusSurface(Worktree, tabID: TerminalTabID, surfaceID: UUID, input: String? = nil) @@ -61,6 +51,9 @@ struct TerminalClient { case setNotificationsEnabled(Bool) case setSelectedWorktreeID(Worktree.ID?) case refreshTabBarVisibility + /// Terminal-kind commands. Exactly one arm per surface kind; the payload is + /// a kind-scoped enum so the neutral surface never grows kind-specific verbs. + case terminal(Worktree, TerminalSurfaceCommand) } enum Event: Equatable { @@ -70,11 +63,7 @@ struct TerminalClient { case tabCreated(worktreeID: Worktree.ID) case tabClosed(worktreeID: Worktree.ID) case focusChanged(worktreeID: Worktree.ID, surfaceID: UUID) - case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) - case blockingScriptCompleted( - worktreeID: Worktree.ID, kind: BlockingScriptKind, exitCode: Int?, tabId: TerminalTabID?) case commandPaletteToggleRequested(worktreeID: Worktree.ID) - case setupScriptConsumed(worktreeID: Worktree.ID) /// Per-worktree projection emitted when surfaces / task-running / unseen / notifications drift. /// Routed by the parent into the matching `SidebarItemFeature` via the row's id. case worktreeProjectionChanged(Worktree.ID, WorktreeRowProjection) @@ -96,17 +85,13 @@ struct TerminalClient { /// `AppFeature` translates this into `agentPresence(.surfaceClosed/surfacesClosed)`. /// `worktreeID` scopes the CLI close ack so a duplicate id elsewhere can't cross-resolve. case surfacesClosed(worktreeID: Worktree.ID, Set) - /// Forwarded from the terminal manager for hook events received over the socket. - /// `AppFeature` translates this into `agentPresence(.hookEventReceived)`. - case agentHookEventReceived(AgentHookEvent) - /// Flips when the "any live surface anywhere" aggregate changes. Lets - /// menu / focused-action gates read one Bool instead of iterating - /// `sidebarItems` from a view body. - case terminalHasAnySurfaceChanged(hasAny: Bool) /// A surface split failed to materialize (target raced away, target was a /// blocking-script tab, or the layout insert threw). Lets a CLI completion /// ack report the failure instead of waiting for its timeout. case surfaceCreationFailed(worktreeID: Worktree.ID, attemptedID: UUID, message: String) + /// Terminal-kind events. Mirror of `Command.terminal`: one arm per kind, + /// kind-scoped payload. + case terminal(TerminalSurfaceEvent) } } diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index 415de78c8..f243163f8 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -102,11 +102,9 @@ extension AppFeature.Action { case .notificationIndicatorChanged: return true case .notificationReceived, .tabCreated, .tabClosed, .focusChanged, - .taskStatusChanged, .blockingScriptCompleted, .commandPaletteToggleRequested, - .setupScriptConsumed, .worktreeProjectionChanged, .tabProjectionChanged, + .commandPaletteToggleRequested, .worktreeProjectionChanged, .tabProjectionChanged, .tabRemoved, .worktreeStateTornDown, .tabProgressDisplayChanged, - .surfacesClosed, .agentHookEventReceived, .terminalHasAnySurfaceChanged, - .surfaceCreationFailed: + .surfacesClosed, .surfaceCreationFailed, .terminal: return false } // Hot agent-storm paths: per-tab churn never mutates snapshot inputs. diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index cec37c21a..2d4dd3d67 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -508,7 +508,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.runBlockingScript(worktree, kind: kind, script: script)) + await terminalClient.send(.terminal(worktree, .runBlockingScript(kind: kind, script: script))) } case .repositories(.delegate(.selectTerminalTab(let worktreeID, let tabId))): @@ -718,7 +718,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.performBindingAction(worktree, action: direction.ghosttyBinding)) + await terminalClient.send(.terminal(worktree, .performBindingAction(direction.ghosttyBinding))) } case .jumpToLatestUnread: @@ -793,7 +793,7 @@ struct AppFeature { // once the script tab is tracked; no optimistic mirror write (#573). return .run { _ in await terminalClient.send( - .runBlockingScript(worktree, kind: .script(definition), script: definition.command) + .terminal(worktree, .runBlockingScript(kind: .script(definition), script: definition.command)) ) } @@ -802,7 +802,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.stopScript(worktree, definitionID: definition.id)) + await terminalClient.send(.terminal(worktree, .stopScript(definitionID: definition.id))) } case .stopRunScripts: @@ -810,7 +810,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.stopRunScript(worktree)) + await terminalClient.send(.terminal(worktree, .stopRunScript)) } case .closeTab: @@ -835,7 +835,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.startSearch(worktree)) + await terminalClient.send(.terminal(worktree, .startSearch)) } case .searchSelection: @@ -843,7 +843,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.searchSelection(worktree)) + await terminalClient.send(.terminal(worktree, .searchSelection)) } case .navigateSearchNext: @@ -851,7 +851,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.navigateSearchNext(worktree)) + await terminalClient.send(.terminal(worktree, .navigateSearchNext)) } case .navigateSearchPrevious: @@ -859,7 +859,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.navigateSearchPrevious(worktree)) + await terminalClient.send(.terminal(worktree, .navigateSearchPrevious)) } case .endSearch: @@ -867,7 +867,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.endSearch(worktree)) + await terminalClient.send(.terminal(worktree, .endSearch)) } case .settings(.repositorySettings(.delegate(.settingsChanged(let rootURL, let host)))): @@ -1203,9 +1203,9 @@ struct AppFeature { let tabID = terminalClient.selectedTabID(worktree.id) command = .beginTabRename(worktree, tabID: tabID) } else if let surfaceID = terminalClient.selectedSurfaceID(worktree.id) { - command = .performBindingActionOnSurface(worktree, surfaceID: surfaceID, action: action) + command = .terminal(worktree, .performBindingActionOnSurface(surfaceID: surfaceID, action: action)) } else { - command = .performBindingAction(worktree, action: action) + command = .terminal(worktree, .performBindingAction(action)) } return .run { _ in await terminalClient.send(command) @@ -1288,9 +1288,10 @@ struct AppFeature { } } - case .terminalEvent(.terminalHasAnySurfaceChanged(let hasAny)): - state.hasAnyTerminalSurface = hasAny - return .none + // Terminal-kind events regroup behind one arm; neutral lifecycle / + // projection events keep their own arms below. + case .terminalEvent(.terminal(let event)): + return handleTerminalSurfaceEvent(event, state: &state) case .terminalEvent(.commandPaletteToggleRequested(let worktreeID)): if state.commandPalette.isPresented { @@ -1302,28 +1303,6 @@ struct AppFeature { .send(.repositories(.selectWorktree(worktreeID))), .send(.commandPalette(.presentInMode(.commands))) ) - case .terminalEvent(.setupScriptConsumed(let worktreeID)): - return .send(.repositories(.consumeSetupScript(worktreeID))) - - case .terminalEvent(.blockingScriptCompleted(let worktreeID, let kind, let exitCode, let tabId)): - switch kind { - case .script: - return .send( - .repositories( - .scriptCompleted( - worktreeID: worktreeID, - kind: kind, - exitCode: exitCode, - tabId: tabId - ) - ) - ) - case .archive: - return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) - case .delete: - return .send(.repositories(.deleteScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) - } - case .terminalEvent(.worktreeProjectionChanged(let worktreeID, var projection)): guard let row = state.repositories.sidebarItems[id: worktreeID] else { return .none } // Archived rows render no running-state dots, so terminal truth must @@ -1434,9 +1413,6 @@ struct AppFeature { : .send(.agentPresence(.surfacesClosed(ids))) return .merge(presenceEffect, ackEffect) - case .terminalEvent(.agentHookEventReceived(let event)): - return .send(.agentPresence(.hookEventReceived(event))) - case .terminalEvent: return .none } @@ -1515,6 +1491,49 @@ struct AppFeature { ) } + /// Terminal-kind event handling, delegated from the single + /// `.terminalEvent(.terminal(_))` reducer arm. A future surface kind adds a + /// sibling handler behind its own wrapper arm; the neutral arms never grow + /// kind-specific cases. + private func handleTerminalSurfaceEvent( + _ event: TerminalSurfaceEvent, + state: inout State + ) -> Effect { + switch event { + case .hasAnySurfaceChanged(let hasAny): + state.hasAnyTerminalSurface = hasAny + return .none + + case .setupScriptConsumed(let worktreeID): + return .send(.repositories(.consumeSetupScript(worktreeID))) + + case .blockingScriptCompleted(let worktreeID, let kind, let exitCode, let tabId): + switch kind { + case .script: + return .send( + .repositories( + .scriptCompleted( + worktreeID: worktreeID, + kind: kind, + exitCode: exitCode, + tabId: tabId + ) + ) + ) + case .archive: + return .send(.repositories(.archiveScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) + case .delete: + return .send(.repositories(.deleteScriptCompleted(worktreeID: worktreeID, exitCode: exitCode, tabId: tabId))) + } + + case .agentHookEventReceived(let event): + return .send(.agentPresence(.hookEventReceived(event))) + + case .taskStatusChanged: + return .none + } + } + /// Re-broadcasts every row's agent snapshot under the supplied badge gate. /// Used when the user flips `agentPresenceBadgesEnabled`, so cached row /// state immediately drains or repopulates without waiting for a hook event. @@ -2156,7 +2175,7 @@ struct AppFeature { // once the script tab is tracked; no optimistic mirror write (#573). return .run { _ in await terminalClient.send( - .runBlockingScript(worktree, kind: .script(definition), script: definition.command) + .terminal(worktree, .runBlockingScript(kind: .script(definition), script: definition.command)) ) } } @@ -2188,7 +2207,7 @@ struct AppFeature { } let terminalClient = terminalClient return .run { _ in - await terminalClient.send(.stopScript(worktree, definitionID: scriptID)) + await terminalClient.send(.terminal(worktree, .stopScript(definitionID: scriptID))) } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 2b1d62b93..20e8ea4ec 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -100,10 +100,10 @@ final class WorktreeTerminalManager { case .worktreeProjectionChanged(let worktreeID, _): .worktreeProjection(worktreeID) case .tabProjectionChanged(_, let projection): .tabProjection(projection.tabID) case .tabProgressDisplayChanged(_, let tabID, _): .tabProgress(tabID) - case .taskStatusChanged(let worktreeID, _): .taskStatus(worktreeID) + case .terminal(.taskStatusChanged(let worktreeID, _)): .taskStatus(worktreeID) case .focusChanged(let worktreeID, _): .focus(worktreeID) case .notificationIndicatorChanged: .notificationIndicator - case .terminalHasAnySurfaceChanged: .hasAnySurface + case .terminal(.hasAnySurfaceChanged): .hasAnySurface default: nil } } @@ -230,7 +230,7 @@ final class WorktreeTerminalManager { } private func applyHookEvent(_ event: AgentHookEvent) { - emit(.agentHookEventReceived(event)) + emit(.terminal(.agentHookEventReceived(event))) } #if DEBUG @@ -263,18 +263,46 @@ final class WorktreeTerminalManager { } func handleCommand(_ command: TerminalClient.Command) { - if handleTabCommand(command) { - return - } - if handleBindingActionCommand(command) { + // Kind-scoped commands dispatch straight to their kind's handler; the + // neutral handlers below never see them. + if case .terminal(let worktree, let surfaceCommand) = command { + handleTerminalSurfaceCommand(surfaceCommand, in: worktree) return } - if handleSearchCommand(command) { + if handleTabCommand(command) { return } handleManagementCommand(command) } + /// Terminal-kind command handler. A future kind adds a sibling handler and + /// one dispatch arm in `handleCommand`; nothing here is shared. + private func handleTerminalSurfaceCommand(_ command: TerminalSurfaceCommand, in worktree: Worktree) { + let terminal = state(for: worktree) + switch command { + case .runBlockingScript(let kind, let script): + _ = terminal.runBlockingScript(kind: kind, script) + case .stopRunScript: + stopBlockingScripts(in: worktree) { $0.stopRunScripts() } + case .stopScript(let definitionID): + stopBlockingScripts(in: worktree) { $0.stopScript(definitionID: definitionID) } + case .performBindingAction(let action): + terminal.performBindingActionOnFocusedSurface(action) + case .performBindingActionOnSurface(let surfaceID, let action): + terminal.performBindingAction(action, onSurfaceID: surfaceID) + case .startSearch: + terminal.performBindingActionOnFocusedSurface("start_search") + case .searchSelection: + terminal.performBindingActionOnFocusedSurface("search_selection") + case .navigateSearchNext: + terminal.navigateSearchOnFocusedSurface(.next) + case .navigateSearchPrevious: + terminal.navigateSearchOnFocusedSurface(.previous) + case .endSearch: + terminal.performBindingActionOnFocusedSurface("end_search") + } + } + // swiftlint:disable:next cyclomatic_complexity private func handleTabCommand(_ command: TerminalClient.Command) -> Bool { switch command { @@ -293,12 +321,6 @@ final class WorktreeTerminalManager { case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): let state = state(for: worktree) { runSetupScriptIfNew } state.ensureInitialTab(focusing: focusing) - case .stopRunScript(let worktree): - stopBlockingScripts(in: worktree) { $0.stopRunScripts() } - case .stopScript(let worktree, let definitionID): - stopBlockingScripts(in: worktree) { $0.stopScript(definitionID: definitionID) } - case .runBlockingScript(let worktree, let kind, let script): - _ = state(for: worktree).runBlockingScript(kind: kind, script) case .closeFocusedTab(let worktree): _ = closeFocusedTab(in: worktree) case .closeFocusedSurface(let worktree): @@ -371,46 +393,6 @@ final class WorktreeTerminalManager { return true } - private func handleSearchCommand(_ command: TerminalClient.Command) -> Bool { - switch command { - case .startSearch(let worktree): - state(for: worktree).performBindingActionOnFocusedSurface("start_search") - case .searchSelection(let worktree): - state(for: worktree).performBindingActionOnFocusedSurface("search_selection") - case .navigateSearchNext(let worktree): - state(for: worktree).navigateSearchOnFocusedSurface(.next) - case .navigateSearchPrevious(let worktree): - state(for: worktree).navigateSearchOnFocusedSurface(.previous) - case .endSearch(let worktree): - state(for: worktree).performBindingActionOnFocusedSurface("end_search") - case .createTab, .ensureInitialTab, .stopRunScript, .stopScript, - .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, - .performBindingActionOnSurface, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, - .destroyTab, .destroySurface, .setImagePasteAgents, .prune, .setNotificationsEnabled, .setSelectedWorktreeID, - .refreshTabBarVisibility, .beginTabRename: - return false - } - return true - } - - private func handleBindingActionCommand(_ command: TerminalClient.Command) -> Bool { - switch command { - case .performBindingAction(let worktree, let action): - state(for: worktree).performBindingActionOnFocusedSurface(action) - case .performBindingActionOnSurface(let worktree, let surfaceID, let action): - state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) - case .setImagePasteAgents(let surfaceID, let agents): - setImagePasteAgents(agents, onSurfaceID: surfaceID) - case .createTab, .ensureInitialTab, .stopRunScript, .stopScript, - .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, - .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, - .focusSurface, .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, - .setSelectedWorktreeID, .refreshTabBarVisibility, .beginTabRename: - return false - } - return true - } - private func setImagePasteAgents(_ agents: Set, onSurfaceID surfaceID: UUID) { for state in states.values where state.setImagePasteAgents(agents, onSurfaceID: surfaceID) { return @@ -439,11 +421,11 @@ final class WorktreeTerminalManager { // event fires; refresh here or the window keeps the previous tint. refreshFocusedSurfaceBackground() terminalLogger.info("Selected worktree \(id?.rawValue ?? "nil")") - case .createTab, .ensureInitialTab, .stopRunScript, .stopScript, - .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, - .performBindingActionOnSurface, .setImagePasteAgents, .startSearch, .searchSelection, .navigateSearchNext, - .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, .focusSurface, - .splitSurface, .destroyTab, .destroySurface, .beginTabRename: + case .setImagePasteAgents(let surfaceID, let agents): + setImagePasteAgents(agents, onSurfaceID: surfaceID) + case .createTab, .ensureInitialTab, .closeFocusedTab, .closeFocusedSurface, .selectTab, + .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, + .beginTabRename, .terminal: assertionFailure("Unhandled terminal command reached management handler: \(command)") } } @@ -577,11 +559,12 @@ final class WorktreeTerminalManager { self?.refreshFocusedSurfaceBackground() } state.onTaskStatusChanged = { [weak self] status in - self?.emit(.taskStatusChanged(worktreeID: worktree.id, status: status)) + self?.emit(.terminal(.taskStatusChanged(worktreeID: worktree.id, status: status))) self?.emitProjection(for: worktree.id) } state.onBlockingScriptCompleted = { [weak self] kind, exitCode, tabId in - self?.emit(.blockingScriptCompleted(worktreeID: worktree.id, kind: kind, exitCode: exitCode, tabId: tabId)) + self?.emit( + .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: kind, exitCode: exitCode, tabId: tabId))) } state.onRunningScriptsChanged = { [weak self] in // Force past the projection dedupe: an archived-strip can clear the row while @@ -592,7 +575,7 @@ final class WorktreeTerminalManager { self?.emit(.commandPaletteToggleRequested(worktreeID: worktree.id)) } state.onSetupScriptConsumed = { [weak self] in - self?.emit(.setupScriptConsumed(worktreeID: worktree.id)) + self?.emit(.terminal(.setupScriptConsumed(worktreeID: worktree.id))) } state.onTabProjectionChanged = { [weak self] projection in self?.emit(.tabProjectionChanged(worktreeID: worktree.id, projection)) @@ -1214,7 +1197,7 @@ final class WorktreeTerminalManager { lastEmittedProjections.removeValue(forKey: worktreeID) case .notificationIndicatorChanged: lastNotificationIndicatorCount = nil - case .terminalHasAnySurfaceChanged(let hasAny): + case .terminal(.hasAnySurfaceChanged(let hasAny)): // Invert instead of nil: the gate defaults nil to false, which would // mask a shed `false` and strand a consumer at `true`. lastEmittedHasAnyTerminalSurface = !hasAny @@ -1288,7 +1271,7 @@ final class WorktreeTerminalManager { let previous = lastEmittedHasAnyTerminalSurface ?? false guard hasAny != previous else { return } lastEmittedHasAnyTerminalSurface = hasAny - emit(.terminalHasAnySurfaceChanged(hasAny: hasAny)) + emit(.terminal(.hasAnySurfaceChanged(hasAny: hasAny))) } /// Runs `stop` on the worktree's existing terminal state, never minting one. diff --git a/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift b/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift new file mode 100644 index 000000000..ffadaed13 --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift @@ -0,0 +1,36 @@ +import Foundation + +/// Terminal-only commands, carried by `TerminalClient.Command.terminal`. The +/// shared command surface stays kind-neutral; everything that only makes sense +/// for a terminal (scripts, Ghostty binding actions, scrollback search) lives +/// here, and a future surface kind adds a sibling enum + one wrapper case +/// instead of new top-level verbs. +enum TerminalSurfaceCommand: Equatable { + case runBlockingScript(kind: BlockingScriptKind, script: String) + case stopRunScript + case stopScript(definitionID: UUID) + case performBindingAction(String) + case performBindingActionOnSurface(surfaceID: UUID, action: String) + case startSearch + case searchSelection + case navigateSearchNext + case navigateSearchPrevious + case endSearch +} + +/// Terminal-only events, carried by `TerminalClient.Event.terminal`. Mirror of +/// `TerminalSurfaceCommand` on the event side: neutral lifecycle / projection +/// events stay top-level on `TerminalClient.Event`. +enum TerminalSurfaceEvent: Equatable { + case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) + case blockingScriptCompleted( + worktreeID: Worktree.ID, kind: BlockingScriptKind, exitCode: Int?, tabId: TerminalTabID?) + case setupScriptConsumed(worktreeID: Worktree.ID) + /// Forwarded from the terminal manager for hook events received over the socket. + /// `AppFeature` translates this into `agentPresence(.hookEventReceived)`. + case agentHookEventReceived(AgentHookEvent) + /// Flips when the "any live terminal surface anywhere" aggregate changes. + /// Lets menu / focused-action gates read one Bool instead of iterating + /// `sidebarItems` from a view body. + case hasAnySurfaceChanged(hasAny: Bool) +} diff --git a/supacodeTests/AgentBusyStateTests.swift b/supacodeTests/AgentBusyStateTests.swift index 1f52db208..b2d81acd4 100644 --- a/supacodeTests/AgentBusyStateTests.swift +++ b/supacodeTests/AgentBusyStateTests.swift @@ -60,8 +60,8 @@ struct AgentBusyStateTests { let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo a")) - manager.handleCommand(.runBlockingScript(worktree, kind: .delete, script: "echo b")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo a"))) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .delete, script: "echo b"))) guard let state = manager.stateIfExists(for: worktree.id) else { Issue.record("Expected worktree state") diff --git a/supacodeTests/AgentPresence+TestHelpers.swift b/supacodeTests/AgentPresence+TestHelpers.swift index 66c732ace..10d5e4925 100644 --- a/supacodeTests/AgentPresence+TestHelpers.swift +++ b/supacodeTests/AgentPresence+TestHelpers.swift @@ -92,7 +92,7 @@ final class PresenceTestHarness { self.parkCount += 1 guard let event = await iterator.next() else { return } switch event { - case .agentHookEventReceived(let payload): + case .terminal(.agentHookEventReceived(let payload)): self.reduce(.hookEventReceived(payload)) case .surfacesClosed(_, let ids): if ids.count == 1, let id = ids.first { diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index 9cd423add..a9e74662e 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -118,7 +118,7 @@ struct AppFeatureCommandPaletteTests { #expect( sent.value == [ - .performBindingActionOnSurface(worktree, surfaceID: surfaceID, action: "goto_split:right") + .terminal(worktree, .performBindingActionOnSurface(surfaceID: surfaceID, action: "goto_split:right")) ] ) } @@ -151,7 +151,7 @@ struct AppFeatureCommandPaletteTests { await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) await store.finish() - #expect(sent.value == [.performBindingAction(worktree, action: "goto_split:right")]) + #expect(sent.value == [.terminal(worktree, .performBindingAction("goto_split:right"))]) } @Test(.dependencies) func ghosttyCommandCapturesSelectedSurfaceBeforeAsyncDispatch() async { @@ -190,7 +190,7 @@ struct AppFeatureCommandPaletteTests { #expect( sent.value == [ - .performBindingActionOnSurface(worktree, surfaceID: firstSurface, action: "toggle_split_zoom") + .terminal(worktree, .performBindingActionOnSurface(surfaceID: firstSurface, action: "toggle_split_zoom")) ] ) } @@ -355,7 +355,7 @@ struct AppFeatureCommandPaletteTests { await store.send(.commandPalette(.delegate(.ghosttyCommand("new_split:right")))) await store.finish() - #expect(sent.value == [.performBindingAction(worktree, action: "new_split:right")]) + #expect(sent.value == [.terminal(worktree, .performBindingAction("new_split:right"))]) } @Test(.dependencies) func closePullRequestDispatchesAction() async { diff --git a/supacodeTests/AppFeatureDeeplinkTests.swift b/supacodeTests/AppFeatureDeeplinkTests.swift index 0e9ab99be..42e734f1c 100644 --- a/supacodeTests/AppFeatureDeeplinkTests.swift +++ b/supacodeTests/AppFeatureDeeplinkTests.swift @@ -447,7 +447,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) let hasRun = sent.value.contains(where: { - if case .runBlockingScript(_, .script(let sentDefinition), _) = $0 { + if case .terminal(_, .runBlockingScript(.script(let sentDefinition), _)) = $0 { return sentDefinition.id == definition.id } return false @@ -492,7 +492,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) let hasStop = sent.value.contains(where: { - if case .stopScript(_, let definitionID) = $0 { return definitionID == definition.id } + if case .terminal(_, .stopScript(let definitionID)) = $0 { return definitionID == definition.id } return false }) #expect(hasStop) @@ -533,7 +533,7 @@ struct AppFeatureDeeplinkTests { await store.finish() let hasRun = sent.value.contains(where: { - if case .runBlockingScript(_, .script(let definition), _) = $0 { + if case .terminal(_, .runBlockingScript(.script(let definition), _)) = $0 { return definition.id == globalScript.id } return false @@ -568,7 +568,7 @@ struct AppFeatureDeeplinkTests { await store.finish() let hasStop = sent.value.contains(where: { - if case .stopScript(_, let definitionID) = $0 { return definitionID == globalScript.id } + if case .terminal(_, .stopScript(let definitionID)) = $0 { return definitionID == globalScript.id } return false }) #expect(hasStop) @@ -608,7 +608,7 @@ struct AppFeatureDeeplinkTests { await store.finish() let runCommands = sent.value.compactMap { command -> ScriptDefinition? in - if case .runBlockingScript(_, .script(let def), _) = command { return def } + if case .terminal(_, .runBlockingScript(.script(let def), _)) = command { return def } return nil } #expect(runCommands.count == 1) @@ -643,7 +643,7 @@ struct AppFeatureDeeplinkTests { await store.send(.deeplink(.worktree(id: worktree.id, action: .stopScript(scriptID: definition.id)))) #expect(store.state.alert != nil) let didStop = sent.value.contains(where: { - if case .stopScript = $0 { return true } + if case .terminal(_, .stopScript) = $0 { return true } return false }) #expect(!didStop) @@ -676,7 +676,7 @@ struct AppFeatureDeeplinkTests { await store.send(.deeplink(.worktree(id: worktree.id, action: .runScript(scriptID: definition.id)))) #expect(store.state.alert != nil) let didRun = sent.value.contains(where: { - if case .runBlockingScript = $0 { return true } + if case .terminal(_, .runBlockingScript) = $0 { return true } return false }) #expect(!didRun) @@ -710,7 +710,7 @@ struct AppFeatureDeeplinkTests { await store.send(.deeplink(.worktree(id: worktree.id, action: .runScript(scriptID: definition.id)))) #expect(store.state.alert != nil) let didRun = sent.value.contains(where: { - if case .runBlockingScript = $0 { return true } + if case .terminal(_, .runBlockingScript) = $0 { return true } return false }) #expect(!didRun) @@ -758,7 +758,7 @@ struct AppFeatureDeeplinkTests { await store.finish() let hasRun = sent.value.contains(where: { - if case .runBlockingScript(_, .script(let sentDefinition), _) = $0 { + if case .terminal(_, .runBlockingScript(.script(let sentDefinition), _)) = $0 { return sentDefinition.id == definition.id } return false diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index fd2ee03a8..b4d6d370a 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -59,7 +59,7 @@ struct AppFeatureRunScriptTests { // terminal's row projection once the script tab is tracked. #expect(store.state.repositories.sidebarItems[id: worktree.id]?.runningScripts.isEmpty == true) #expect(sent.value.count == 1) - guard case .runBlockingScript(let sentWorktree, let kind, let script) = sent.value.first else { + guard case .terminal(let sentWorktree, .runBlockingScript(let kind, let script)) = sent.value.first else { Issue.record("Expected runBlockingScript command") return } @@ -178,11 +178,13 @@ struct AppFeatureRunScriptTests { await store.send( .terminalEvent( - .blockingScriptCompleted( - worktreeID: worktree.id, - kind: .script(definition), - exitCode: 0, - tabId: nil + .terminal( + .blockingScriptCompleted( + worktreeID: worktree.id, + kind: .script(definition), + exitCode: 0, + tabId: nil + ) ) ) ) @@ -268,7 +270,7 @@ struct AppFeatureRunScriptTests { await store.finish() #expect(sent.value.count == 1) - guard case .stopRunScript(let sentWorktree) = sent.value.first else { + guard case .terminal(let sentWorktree, .stopRunScript) = sent.value.first else { Issue.record("Expected stopRunScript command") return } @@ -297,7 +299,7 @@ struct AppFeatureRunScriptTests { await store.finish() #expect(sent.value.count == 1) - guard case .stopScript(let sentWorktree, let definitionID) = sent.value.first else { + guard case .terminal(let sentWorktree, .stopScript(let definitionID)) = sent.value.first else { Issue.record("Expected stopScript command") return } @@ -394,7 +396,7 @@ struct AppFeatureRunScriptTests { await store.finish() #expect(sent.value.count == 1) - guard case .runBlockingScript(_, let kind, let script) = sent.value.first else { + guard case .terminal(_, .runBlockingScript(let kind, let script)) = sent.value.first else { Issue.record("Expected runBlockingScript command") return } @@ -486,7 +488,7 @@ struct AppFeatureRunScriptTests { await store.finish() let runCommands = sent.value.compactMap { command -> ScriptDefinition? in - if case .runBlockingScript(_, .script(let def), _) = command { return def } + if case .terminal(_, .runBlockingScript(.script(let def), _)) = command { return def } return nil } #expect(runCommands.count == 1) @@ -517,7 +519,7 @@ struct AppFeatureRunScriptTests { await store.finish() let runCommands = sent.value.compactMap { command -> ScriptDefinition? in - if case .runBlockingScript(_, .script(let def), _) = command { return def } + if case .terminal(_, .runBlockingScript(.script(let def), _)) = command { return def } return nil } #expect(runCommands.count == 1) diff --git a/supacodeTests/AppFeatureSplitTerminalTests.swift b/supacodeTests/AppFeatureSplitTerminalTests.swift index aeedc45ae..67d496ce6 100644 --- a/supacodeTests/AppFeatureSplitTerminalTests.swift +++ b/supacodeTests/AppFeatureSplitTerminalTests.swift @@ -39,7 +39,7 @@ struct AppFeatureSplitTerminalTests { await store.send(.splitTerminal(direction)) await store.finish() - #expect(sent.value == [.performBindingAction(worktree, action: direction.ghosttyBinding)]) + #expect(sent.value == [.terminal(worktree, .performBindingAction(direction.ghosttyBinding))]) } @Test(.dependencies) func splitTerminalWithoutSelectionIsNoop() async { diff --git a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift index 1bb7d4360..c63fce006 100644 --- a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift +++ b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift @@ -31,7 +31,7 @@ struct AppFeatureTerminalSetupScriptTests { } await store.send(.newTerminal) - await store.send(.terminalEvent(.setupScriptConsumed(worktreeID: worktree.id))) + await store.send(.terminalEvent(.terminal(.setupScriptConsumed(worktreeID: worktree.id)))) await store.receive(\.repositories.consumeSetupScript) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.lifecycle = .idle @@ -104,7 +104,7 @@ struct AppFeatureTerminalSetupScriptTests { AppFeature() } - await store.send(.terminalEvent(.setupScriptConsumed(worktreeID: worktree.id))) + await store.send(.terminalEvent(.terminal(.setupScriptConsumed(worktreeID: worktree.id)))) await store.receive(\.repositories.consumeSetupScript) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.lifecycle = .idle diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index c05bc3e3c..81725cd53 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -105,13 +105,13 @@ struct WorktreeTerminalManagerTests { let stream = manager.eventStream() let event = await nextEvent(stream) { event in - if case .setupScriptConsumed = event { + if case .terminal(.setupScriptConsumed) = event { return true } return false } - #expect(event == .setupScriptConsumed(worktreeID: worktree.id)) + #expect(event == .terminal(.setupScriptConsumed(worktreeID: worktree.id))) } @Test func emitsEventsAfterStreamCreated() async { @@ -122,7 +122,7 @@ struct WorktreeTerminalManagerTests { let stream = manager.eventStream() let eventTask = Task { await nextEvent(stream) { event in - if case .setupScriptConsumed = event { + if case .terminal(.setupScriptConsumed) = event { return true } return false @@ -132,7 +132,7 @@ struct WorktreeTerminalManagerTests { state.onSetupScriptConsumed?() let event = await eventTask.value - #expect(event == .setupScriptConsumed(worktreeID: worktree.id)) + #expect(event == .terminal(.setupScriptConsumed(worktreeID: worktree.id))) } @Test func unavailableSocketServerIsDiscarded() { @@ -152,7 +152,7 @@ struct WorktreeTerminalManagerTests { let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server) let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -175,7 +175,7 @@ struct WorktreeTerminalManagerTests { let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting @@ -206,7 +206,7 @@ struct WorktreeTerminalManagerTests { let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting @@ -234,7 +234,7 @@ struct WorktreeTerminalManagerTests { let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting @@ -267,7 +267,7 @@ struct WorktreeTerminalManagerTests { let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting @@ -295,7 +295,7 @@ struct WorktreeTerminalManagerTests { let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting @@ -323,7 +323,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id) else { Issue.record("Expected terminal state for worktree") return @@ -335,11 +335,11 @@ struct WorktreeTerminalManagerTests { ) // Lifecycle kinds carry no definition ID and never surface as running scripts. - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) #expect(state.currentProjection().runningScripts.map(\.id) == [definition.id]) // Stopping the script reconciles the projection back to empty (#573). - manager.handleCommand(.stopScript(worktree, definitionID: definition.id)) + manager.handleCommand(.terminal(worktree, .stopScript(definitionID: definition.id))) #expect(state.currentProjection().runningScripts.isEmpty) } @@ -353,11 +353,11 @@ struct WorktreeTerminalManagerTests { // A double resume would trap, so each leg also pins "one callback per turn". await withCheckedContinuation { continuation in state.onRunningScriptsChanged = { continuation.resume() } - manager.handleCommand(.runBlockingScript(worktree, kind: .script(first), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(first), script: "echo ok"))) } await withCheckedContinuation { continuation in state.onRunningScriptsChanged = { continuation.resume() } - manager.handleCommand(.runBlockingScript(worktree, kind: .script(second), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(second), script: "echo ok"))) } #expect(Set(state.currentProjection().runningScripts.map(\.id)) == [first.id, second.id]) @@ -382,11 +382,11 @@ struct WorktreeTerminalManagerTests { await withCheckedContinuation { continuation in state.onRunningScriptsChanged = { continuation.resume() } - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "echo ok"))) } let tabsBefore = state.tabManager.tabs.map(\.id) - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "echo ok"))) #expect(state.tabManager.tabs.map(\.id) == tabsBefore) #expect(state.currentProjection().runningScripts.map(\.id) == [definition.id]) @@ -426,13 +426,13 @@ struct WorktreeTerminalManagerTests { _ = manager.state(for: worktree) let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "echo ok"))) var running = await nextRunningScripts(stream) while running?.isEmpty == true { running = await nextRunningScripts(stream) } #expect(running?.map(\.id) == [definition.id]) // Duplicate run: the running set is unchanged, so a plain emit would dedupe. - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "echo ok"))) var reAsserted = await nextRunningScripts(stream) while reAsserted?.isEmpty == true { reAsserted = await nextRunningScripts(stream) } #expect(reAsserted?.map(\.id) == [definition.id]) @@ -460,12 +460,12 @@ struct WorktreeTerminalManagerTests { let definition = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "echo ok"))) var started = await nextRunningScripts(stream) while started?.isEmpty == true { started = await nextRunningScripts(stream) } #expect(started?.map(\.id) == [definition.id]) - manager.handleCommand(.stopScript(worktree, definitionID: definition.id)) + manager.handleCommand(.terminal(worktree, .stopScript(definitionID: definition.id))) var stopped = await nextRunningScripts(stream) while stopped?.isEmpty == false { stopped = await nextRunningScripts(stream) } #expect(stopped?.isEmpty == true) @@ -482,7 +482,7 @@ struct WorktreeTerminalManagerTests { // forced re-emit (without it, this await hangs). _ = await nextRunningScripts(stream) - manager.handleCommand(.stopScript(worktree, definitionID: UUID())) + manager.handleCommand(.terminal(worktree, .stopScript(definitionID: UUID()))) let reEmitted = await nextRunningScripts(stream) #expect(reEmitted?.isEmpty == true) } @@ -591,8 +591,8 @@ struct WorktreeTerminalManagerTests { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() - manager.handleCommand(.stopScript(worktree, definitionID: UUID())) - manager.handleCommand(.stopRunScript(worktree)) + manager.handleCommand(.terminal(worktree, .stopScript(definitionID: UUID()))) + manager.handleCommand(.terminal(worktree, .stopRunScript)) #expect(manager.stateIfExists(for: worktree.id) == nil) } @@ -605,7 +605,7 @@ struct WorktreeTerminalManagerTests { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -662,7 +662,7 @@ struct WorktreeTerminalManagerTests { while case .worktreeProjectionChanged = second { second = await iterator.next() } #expect(first == .notificationIndicatorChanged(count: 0)) - #expect(second == .setupScriptConsumed(worktreeID: worktree.id)) + #expect(second == .terminal(.setupScriptConsumed(worktreeID: worktree.id))) } @Test func presenceHasActivityReflectsAnyBusySurface() { @@ -1768,7 +1768,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe) let worktree = makeWorktree() let state = manager.state(for: worktree) - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { @@ -1863,7 +1863,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "exit 1")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "exit 1"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -1876,13 +1876,14 @@ struct WorktreeTerminalManagerTests { surface.bridge.onCommandFinished?(1) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1, tabId: tabId)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1, tabId: tabId))) } @Test func blockingScriptCompletionPassesNilExitCodeWhenCommandFinishedReportsNil() async { @@ -1890,7 +1891,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -1903,13 +1904,15 @@ struct WorktreeTerminalManagerTests { surface.bridge.onCommandFinished?(nil) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: tabId)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: tabId)) + ) } @Test func blockingScriptCommandFinishedFollowedByChildExitDoesNotDoubleFire() async { @@ -1917,7 +1920,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -1933,12 +1936,13 @@ struct WorktreeTerminalManagerTests { // First completion event should arrive. let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: tabId)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: tabId))) // The child exit should NOT produce a second completion. #expect(!manager.isBlockingScriptRunning(kind: .archive, for: worktree.id)) @@ -1949,7 +1953,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -1962,13 +1966,14 @@ struct WorktreeTerminalManagerTests { surface.bridge.onChildExited?(1) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil))) } @Test func remoteBlockingScriptChildExitReportsFailure() async { @@ -1979,7 +1984,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeRemoteWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -1992,13 +1997,14 @@ struct WorktreeTerminalManagerTests { surface.terminalForTesting.bridge.onChildExited?(0) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1, tabId: nil)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 1, tabId: nil))) } @Test func blockingScriptSignalBasedTerminationReportsImmediately() async { @@ -2006,7 +2012,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -2021,13 +2027,15 @@ struct WorktreeTerminalManagerTests { surface.bridge.onCommandFinished?(130) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 130, tabId: tabId)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 130, tabId: tabId)) + ) } @Test func blockingScriptRerunClosesOldTabWithoutFiringCompletion() async { @@ -2035,7 +2043,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let state = manager.stateIfExists(for: worktree.id), let firstTabId = state.tabManager.selectedTabId @@ -2045,7 +2053,7 @@ struct WorktreeTerminalManagerTests { } // Re-run the same kind — old tab should close silently. - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let secondTabId = state.tabManager.selectedTabId else { Issue.record("Expected second blocking script tab") @@ -2063,13 +2071,16 @@ struct WorktreeTerminalManagerTests { surface.bridge.onCommandFinished?(0) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: secondTabId)) + #expect( + event + == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: secondTabId)) + ) } @Test func blockingScriptTabClosedManuallyReportsCancellation() async { @@ -2077,7 +2088,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId @@ -2090,13 +2101,14 @@ struct WorktreeTerminalManagerTests { state.closeTab(tabId) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil))) } @Test func closeAllSurfacesCancelsPendingBlockingScripts() async { @@ -2104,7 +2116,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let state = manager.stateIfExists(for: worktree.id) else { Issue.record("Expected worktree state") @@ -2114,13 +2126,14 @@ struct WorktreeTerminalManagerTests { state.closeAllSurfaces() let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: nil, tabId: nil))) } @Test func blockingScriptSuccessKeepsTabOpen() async { @@ -2128,7 +2141,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -2143,13 +2156,14 @@ struct WorktreeTerminalManagerTests { surface.bridge.onCommandFinished?(0) let event = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { + if case .terminal(.blockingScriptCompleted) = event { return true } return false } - #expect(event == .blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: tabId)) + #expect( + event == .terminal(.blockingScriptCompleted(worktreeID: worktree.id, kind: .archive, exitCode: 0, tabId: tabId))) // Tab stays open so the user can inspect output. #expect(state.tabManager.tabs.map(\.id).contains(tabId)) } @@ -2161,7 +2175,7 @@ struct WorktreeTerminalManagerTests { #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == false) - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "echo hi")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "echo hi"))) #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == true) } @@ -2171,10 +2185,10 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, command: "sleep 10") - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "sleep 10"))) #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == true) - manager.handleCommand(.stopRunScript(worktree)) + manager.handleCommand(.terminal(worktree, .stopRunScript)) #expect(manager.isBlockingScriptRunning(kind: .script(definition), for: worktree.id) == false) } @@ -2184,7 +2198,7 @@ struct WorktreeTerminalManagerTests { let stream = manager.eventStream() let definition = ScriptDefinition(kind: .run, command: "sleep 10") - manager.handleCommand(.runBlockingScript(worktree, kind: .script(definition), script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .script(definition), script: "sleep 10"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -2206,7 +2220,7 @@ struct WorktreeTerminalManagerTests { // Wait for completion event. _ = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { return true } + if case .terminal(.blockingScriptCompleted) = event { return true } return false } @@ -2229,7 +2243,7 @@ struct WorktreeTerminalManagerTests { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "exit 1")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "exit 1"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -2259,7 +2273,7 @@ struct WorktreeTerminalManagerTests { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() // First archive run, complete it, and confirm the tab is frozen. - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo first")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo first"))) guard let state = manager.stateIfExists(for: worktree.id), let firstTabId = state.tabManager.selectedTabId, let firstSurface = state.splitTree(for: firstTabId).root?.leftmostLeaf().terminalForTesting @@ -2274,7 +2288,7 @@ struct WorktreeTerminalManagerTests { // Re-run archive: the lingering frozen tab must be closed and a fresh tab // minted. Without the cleanup the old tab would still be selected and the // user would never see the new run. - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo second")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo second"))) let secondTabId = state.tabManager.selectedTabId #expect(secondTabId != nil) #expect(secondTabId != firstTabId) @@ -2285,7 +2299,7 @@ struct WorktreeTerminalManagerTests { @Test func residualProgressReportDoesNotResurrectDirtyOnFrozenTab() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting @@ -2311,8 +2325,8 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() // Create two blocking script tabs so we have two tabs to switch between. - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo archive")) - manager.handleCommand(.runBlockingScript(worktree, kind: .delete, script: "echo delete")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo archive"))) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .delete, script: "echo delete"))) guard let state = manager.stateIfExists(for: worktree.id) else { Issue.record("Expected worktree state") @@ -2340,7 +2354,7 @@ struct WorktreeTerminalManagerTests { let (manager, _) = WorktreeTerminalManager.withPresenceHarness() let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId @@ -2616,7 +2630,7 @@ struct WorktreeTerminalManagerTests { var statuses: [WorktreeTaskStatus] = [] for await event in stream { - guard case .taskStatusChanged(_, let status) = event else { continue } + guard case .terminal(.taskStatusChanged(_, let status)) = event else { continue } statuses.append(status) } @@ -2664,7 +2678,7 @@ struct WorktreeTerminalManagerTests { var count = 0 for await event in stream { - if case .setupScriptConsumed = event { count += 1 } + if case .terminal(.setupScriptConsumed) = event { count += 1 } } #expect(count == manager.eventBufferCap) @@ -2736,7 +2750,7 @@ struct WorktreeTerminalManagerTests { var statuses: [WorktreeTaskStatus] = [] for await event in stream { - guard case .taskStatusChanged(_, let status) = event else { continue } + guard case .terminal(.taskStatusChanged(_, let status)) = event else { continue } statuses.append(status) } @@ -2759,7 +2773,7 @@ struct WorktreeTerminalManagerTests { var count = 0 for await event in stream { - if case .setupScriptConsumed = event { count += 1 } + if case .terminal(.setupScriptConsumed) = event { count += 1 } } #expect(count == WorktreeTerminalManager.pendingEventCap) @@ -2780,7 +2794,7 @@ struct WorktreeTerminalManagerTests { var statuses: [WorktreeTaskStatus] = [] for await event in stream { - guard case .taskStatusChanged(_, let status) = event else { continue } + guard case .terminal(.taskStatusChanged(_, let status)) = event else { continue } statuses.append(status) } @@ -3111,7 +3125,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let state = manager.state(for: worktree) _ = state.createTab() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) #expect(state.tabManager.tabs.count == 2) @@ -3130,7 +3144,7 @@ struct WorktreeTerminalManagerTests { let worktree = makeWorktree() let stream = manager.eventStream() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, @@ -3142,7 +3156,7 @@ struct WorktreeTerminalManagerTests { surface.bridge.onCommandFinished?(0) _ = await nextEvent(stream) { event in - if case .blockingScriptCompleted = event { return true } + if case .terminal(.blockingScriptCompleted) = event { return true } return false } // After completion the tab keeps its title / icon / lock and stays @@ -3159,7 +3173,7 @@ struct WorktreeTerminalManagerTests { Issue.record("Expected first regular tab") return } - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let tabB = state.tabManager.selectedTabId else { Issue.record("Expected blocking-script tab selected after runBlockingScript") return @@ -3187,7 +3201,7 @@ struct WorktreeTerminalManagerTests { @Test func performSplitActionRefusesNewSplitOnBlockingScriptTab() { let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let state = manager.stateIfExists(for: worktree.id), let tabId = state.tabManager.selectedTabId, let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting @@ -3212,7 +3226,7 @@ struct WorktreeTerminalManagerTests { Issue.record("Expected regular tab and surface") return } - manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "sleep 10")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let blockingTab = state.tabManager.selectedTabId, let blockingSurface = state.splitTree(for: blockingTab).root?.leftmostLeaf().terminalForTesting else { From c540d77b31e294a1353323c9b44aab20ed785c4b Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Fri, 10 Jul 2026 19:49:21 -0400 Subject: [PATCH 06/13] Move terminal-only worktree state into a per-kind TerminalSurfaceStore WorktreeTerminalState's generic core (tree / tab / focus / occlusion / zoom / persistence scheduling) no longer declares terminal-kind state. The Ghostty view map, zmx launch/session bookkeeping, blocking-script tracking, setup-script flag, environment injection, and agent-OSC notification dedupe now live in TerminalSurfaceStore, together with the launch-resolution and environment helpers that only touch that state. Private accessors keep the orchestration bodies unchanged, so the diff is the ownership move, not a rewrite. Per-kind stores are keyed by surface/tab ID and empty when the kind is absent, so additional kinds don't multiply per-worktree state. --- .../Models/TerminalSurfaceStore.swift | 272 +++++++++++++++ .../Models/WorktreeTerminalState.swift | 322 ++++-------------- .../RemoteRepositorySidebarTests.swift | 8 +- 3 files changed, 344 insertions(+), 258 deletions(-) create mode 100644 supacode/Features/Terminal/Models/TerminalSurfaceStore.swift diff --git a/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift b/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift new file mode 100644 index 000000000..ad2acc25b --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift @@ -0,0 +1,272 @@ +import AppKit +import Dependencies +import Foundation +import GhosttyKit +import Sharing +import SupacodeSettingsShared + +private let blockingScriptLogger = SupaLogger("BlockingScript") +private let storeLogger = SupaLogger("Terminal") + +/// Per-worktree store for terminal-kind state: the Ghostty view map, zmx +/// launch/session bookkeeping, blocking scripts, setup-script consumption, +/// working-dir/environment injection, and agent-OSC notification dedupe. +/// +/// `WorktreeTerminalState`'s generic core (tree / tab / focus / occlusion / +/// zoom / persistence scheduling) reaches terminal-only state exclusively +/// through this store, so a future surface kind adds a sibling store instead +/// of new fields on the shared class. Everything here is keyed by surface or +/// tab ID and empty when no terminal surface exists, so absent kinds cost +/// nothing (no state multiplication). +@MainActor +@Observable +final class TerminalSurfaceStore { + struct SurfaceLaunchMetadata { + let usesZmx: Bool + let context: ghostty_surface_context_e + } + + struct ResolvedLaunch { + var command: String? + var initialInput: String? + var commandWrapper: [String] + var usesZmx: Bool + } + + private let worktree: Worktree + var socketPath: String? + @ObservationIgnored + @SharedReader private var repositorySettings: RepositorySettings + @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient + @ObservationIgnored @Dependency(\.analyticsClient) private var analyticsClient + + @ObservationIgnored var surfaces: [UUID: GhosttySurfaceView] = [:] + // `usesZmx` + `context` retained per surface so an unexpected zmx exit can recreate it on reattach. + @ObservationIgnored var surfaceLaunchMetadata: [UUID: SurfaceLaunchMetadata] = [:] + // Surfaces the user explicitly closed, so an unexpected zmx exit isn't mistaken for one and reattached. + @ObservationIgnored var pendingExplicitSurfaceCloseIDs: Set = [] + var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] + var blockingScriptLaunchDirectories: [TerminalTabID: URL] = [:] + var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] = [:] + var pendingSetupScript: Bool + /// When a custom (hook / OSC 3008) notification last committed per surface. + /// Stored as a monotonic instant so the suppression window and the OSC-9 hold + /// share one clock source and can't desync on an NTP step / manual clock change. + var lastCustomNotificationAt: [UUID: any InstantProtocol] = [:] + /// Agent OSC 9 notifications held to see if a custom notification supersedes them. + var pendingAgentOSCNotifications: [UUID: Task] = [:] + + init(worktree: Worktree, pendingSetupScript: Bool) { + self.worktree = worktree + self.pendingSetupScript = pendingSetupScript + _repositorySettings = SharedReader( + wrappedValue: RepositorySettings.default, + .repositorySettings(worktree.repositoryRootURL, host: worktree.host) + ) + } + + func setupScriptInput(setupScript: String?) -> String? { + guard pendingSetupScript, let script = setupScript else { return nil } + return BlockingScriptRunner.makeCommandInput(script: script) + } + + func cleanupBlockingScriptLaunchDirectory(for tabId: TerminalTabID) { + guard let directoryURL = blockingScriptLaunchDirectories.removeValue(forKey: tabId) else { return } + cleanupBlockingScriptLaunchDirectory(at: directoryURL) + } + + func cleanupBlockingScriptLaunchDirectories() { + let directoryURLs = blockingScriptLaunchDirectories.values + blockingScriptLaunchDirectories.removeAll() + for directoryURL in directoryURLs { + cleanupBlockingScriptLaunchDirectory(at: directoryURL) + } + } + + func cleanupBlockingScriptLaunchDirectory(at directoryURL: URL) { + do { + try FileManager.default.removeItem(at: directoryURL) + } catch { + blockingScriptLogger.warning( + "Failed to remove blocking script launch directory \(directoryURL.path(percentEncoded: false)): \(error)" + ) + } + } + + // The typed command stays shell-portable by invoking a generated wrapper file + // that reads the shell path from a sibling file and launches the user script, + // rather than serializing it into a shell-escaped `-c` string. + func blockingScriptLaunch(_ script: String) throws -> BlockingScriptRunner.LaunchArtifacts? { + try BlockingScriptRunner.makeLaunch( + script: script, + shellPath: defaultShellPath() + ) + } + + func surfaceEnvironment(tabId: TerminalTabID, surfaceID: UUID) -> [String: String] { + var env = worktree.scriptEnvironment + let percentEncodingSet = CharacterSet.urlPathAllowed.subtracting(.init(charactersIn: "/")) + let repoPath = worktree.repositoryRootURL.path(percentEncoded: false) + env["SUPACODE_REPO_ID"] = percentEncode(repoPath, allowedCharacters: percentEncodingSet, label: "SUPACODE_REPO_ID") + env["SUPACODE_WORKTREE_ID"] = percentEncode( + worktree.id.rawValue, allowedCharacters: percentEncodingSet, label: "SUPACODE_WORKTREE_ID") + env["SUPACODE_TAB_ID"] = tabId.rawValue.uuidString + env["SUPACODE_SURFACE_ID"] = surfaceID.uuidString + if let socketPath { + env["SUPACODE_SOCKET_PATH"] = socketPath + } + // Mark blocking-script surfaces so the user's shell profile can skip its + // interactive init (prompt, plugins, banners) for these transient tabs. + if let blockingScriptKind = blockingScripts[tabId] { + env.merge(blockingScriptEnvironment(for: blockingScriptKind)) { _, new in new } + } + // Lock ZMX_DIR to the value the app's probe used so the shell can't + // re-export a different value from .zshrc / .zprofile and silently + // overflow `sockaddr_un.sun_path` past the probe's check. + env["ZMX_DIR"] = ZmxSocketBudget.socketDir() + // Prepend the bundled CLI binary directory to PATH so that `supacode` + // resolves to the CLI tool, not the app binary added by Ghostty. + if let cliBinDir = Bundle.main.resourceURL? + .appending(path: "bin", directoryHint: .isDirectory) + .path(percentEncoded: false) + { + let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "" + env["PATH"] = currentPath.isEmpty ? cliBinDir : "\(cliBinDir):\(currentPath)" + } + return env + } + + /// Blocking-script marker env vars for a kind, with scope resolved against + /// this worktree's settings. Shared by the local surface environment and the + /// remote runner export so both hosts expose the same signal. + func blockingScriptEnvironment(for kind: BlockingScriptKind) -> [String: String] { + let scope = kind.scriptDefinitionID.flatMap(scriptScope(forDefinitionID:)) + return kind.surfaceEnvironmentVariables(scope: scope) + } + + /// Resolves whether a user-defined script is repo- or global-owned, mirroring + /// the repo-wins merge: an ID present in repo settings is `.repo`, otherwise + /// `.global`. Returns `nil` for a script that resolves to neither (e.g. a + /// since-deleted deeplink target). + private func scriptScope(forDefinitionID id: UUID) -> ScriptScope? { + if repositorySettings.scripts.contains(where: { $0.id == id }) { return .repo } + @Shared(.settingsFile) var settingsFile + if settingsFile.global.globalScripts.contains(where: { $0.id == id }) { return .global } + return nil + } + + private func percentEncode(_ value: String, allowedCharacters: CharacterSet, label: String) -> String { + guard let encoded = value.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { + storeLogger.warning( + "Failed to percent-encode \(label): \(value). Downstream deeplinks using this value may be malformed.") + return value + } + return encoded + } + + /// Routes a surface through zmx so the underlying shell survives app quit. + /// + /// Interactive surfaces (no explicit `command`) keep `command` nil and inject + /// `zmx attach ` as a Ghostty `command-wrapper`, so Ghostty resolves and + /// integrates the user's real shell exactly as it would without zmx, with zmx + /// wrapping the whole resolved (login + integrated) argv. + /// + /// Explicit commands (scripts) instead wrap the command string itself, since + /// they don't want shell resolution / integration. `initialInput` is always + /// passed through; zmx is authoritative for attach-vs-create. + func resolveLaunch( + surfaceID: UUID, + command: String?, + initialInput: String?, + bypassZmx: Bool + ) -> ResolvedLaunch { + if bypassZmx { + return ResolvedLaunch(command: command, initialInput: initialInput, commandWrapper: [], usesZmx: false) + } + let zmxExecutablePath = zmxClient.executableURL()?.path(percentEncoded: false) + // Remote worktree: a *local* zmx session wraps a reconnect loop around the + // SSH connection, and the remote reattaches its own zmx session when the + // host has zmx (host persistence). The surface command is always the + // reconnect-loop script (no command-wrapper, since Ghostty wraps the + // local argv, not the loop). When the caller has no explicit command, + // default to cd-into-the-remote-dir so a freshly created session lands in + // the project. + if let host = worktree.host { + @Shared(.settingsFile) var settingsFile + let hostPersistence = settingsFile.global.remoteSessionPersistenceEnabled + let launch = ZmxAttach.RemoteSurfaceLaunch( + host: host, + surfaceID: surfaceID, + userCommand: command, + defaultCommand: Self.remoteDefaultShellCommand( + remotePath: worktree.workingDirectory.path(percentEncoded: false)), + hostPersistenceEnabled: hostPersistence, + ) + return ResolvedLaunch( + command: ZmxAttach.buildRemoteCommand(launch, localZmxExecutablePath: zmxExecutablePath), + initialInput: initialInput, + commandWrapper: [], + usesZmx: zmxExecutablePath != nil, + ) + } + let resolved = ZmxAttach.resolveLaunch( + executablePath: zmxExecutablePath, + sessionID: ZmxSessionID.make(surfaceID: surfaceID), + command: command, + ) + return ResolvedLaunch( + command: resolved.command, + initialInput: initialInput, + commandWrapper: resolved.commandWrapper, + usesZmx: zmxExecutablePath != nil, + ) + } + + /// Connect default and reconnect fallback for a remote surface: `cd` into + /// the remote project dir, then exec a login shell. The `cd` failure is + /// swallowed so a stale path still drops the user into a usable shell. Nil + /// for an empty/root path falls back to a bare login shell. The path is + /// single-quoted for the login shell that re-parses the session command. + static func remoteDefaultShellCommand(remotePath: String) -> String? { + let trimmed = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "/" else { return nil } + let quoted = "'" + trimmed.replacing("'", with: "'\\''") + "'" + return "cd \(quoted) 2>/dev/null; exec \"$SHELL\" -l" + } + + /// Tears down persistent zmx sessions for surfaces the user just closed. + /// `isBundled` (not `executableURL`) is the gate so sessions created on a + /// previous under-budget launch still tear down when this launch exceeds the + /// socket budget. One analytics event + one `withTaskGroup` per call. + /// `includeRemote` also tears down the host-side sessions of a remote + /// worktree; only explicit close paths set it, so a non-explicit end (clean + /// remote exit, deliberate host-side detach, or a reconnect abort) spares + /// the host session. The remote kill is unconditional on explicit close (no + /// per-surface persistence gate): a host session may exist from an earlier + /// launch regardless of the current toggle, and the kill invocation is a + /// silent no-op when nothing exists. + func killZmxSessions(forSurfaceIDs surfaceIDs: [UUID], includeRemote: Bool = false) { + guard !surfaceIDs.isEmpty else { return } + let killLocal = zmxClient.isBundled() + let host = includeRemote ? worktree.host : nil + guard killLocal || host != nil else { return } + let sessionIDs = surfaceIDs.map(ZmxSessionID.make(surfaceID:)) + let client = zmxClient + analyticsClient.capture( + "terminal_persistence_session_killed", + [ + "reason": "user_close", "count": killLocal ? sessionIDs.count : 0, + "remote_count": host == nil ? 0 : sessionIDs.count, + ] + ) + Task.detached { + await withTaskGroup(of: Void.self) { group in + for id in sessionIDs { + group.addTask { + await client.killSurfaceSessions(sessionID: id, remoteHost: host, killLocal: killLocal) + } + } + } + } + } +} diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index d35258cf4..1c6a84657 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -50,12 +50,12 @@ final class WorktreeTerminalState { let isFocused: Bool } - private struct SurfaceLaunchMetadata { - let usesZmx: Bool - let context: ghostty_surface_context_e - } - let tabManager: TerminalTabManager + /// Terminal-kind store: Ghostty view map, zmx/launch bookkeeping, blocking + /// scripts, setup-script flag, env injection, agent-OSC dedupe. The private + /// accessors below keep the generic-core method bodies unchanged while the + /// state itself lives per-kind; a future surface kind adds a sibling store. + @ObservationIgnored let terminal: TerminalSurfaceStore private let runtime: GhosttyRuntime @ObservationIgnored private let splitPreserveZoomOnNavigation: () -> Bool private let worktree: Worktree @@ -65,15 +65,10 @@ final class WorktreeTerminalState { // from user-initiated structural changes; per-surface churn must stay on // `surfaceStates` / `WorktreeTabProjection` to keep agent storms cold. private var trees: [TerminalTabID: SplitTree] = [:] - @ObservationIgnored private var surfaces: [UUID: GhosttySurfaceView] = [:] /// Owns pane-rearrange drag state for this worktree's terminal area. The view /// layer reads `isDragging` to mount drop catchers; `onDrop` (wired in `init`) /// routes a landed drop back into `performSplitOperation`. @ObservationIgnored let dragCoordinator = SurfaceDragCoordinator() - // `usesZmx` + `context` retained per surface so an unexpected zmx exit can recreate it on reattach. - @ObservationIgnored private var surfaceLaunchMetadata: [UUID: SurfaceLaunchMetadata] = [:] - // Surfaces the user explicitly closed, so an unexpected zmx exit isn't mistaken for one and reattached. - @ObservationIgnored private var pendingExplicitSurfaceCloseIDs: Set = [] @ObservationIgnored private var surfaceGenerationByTab: [TerminalTabID: Int] = [:] @ObservationIgnored private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] /// Per-tab projection cache. `WorktreeTerminalState` recomputes from `trees` @@ -84,20 +79,11 @@ final class WorktreeTerminalState { /// Per-tab progress-display cache. Tracks the focused-surface or worst-of /// aggregate so `onTabProgressDisplayChanged` only fires on diff. @ObservationIgnored private var lastTabProgressDisplays: [TerminalTabID: TerminalTabProgressDisplay?] = [:] - var socketPath: String? private(set) var shouldHideTabBar = false - // Every mutation schedules a coalesced row-projection emit so the TCA - // mirror of running scripts reconciles from this single source of truth (#573). - private var blockingScripts: [TerminalTabID: BlockingScriptKind] = [:] { - didSet { scheduleRunningScriptsProjectionEmit() } - } - /// Coalesces the per-mutation `didSet` into one next-tick emit so + /// Coalesces the per-mutation running-scripts emit into one next-tick emit so /// mid-operation states (e.g. the supersede clear-then-record in /// `runBlockingScript`) never reach TCA. @ObservationIgnored private var pendingRunningScriptsProjectionEmit = false - private var blockingScriptLaunchDirectories: [TerminalTabID: URL] = [:] - private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] = [:] - private var pendingSetupScript: Bool /// Sticky after first attempt so a reselect after `closeAllTabs` doesn't auto-recreate. /// Intentionally never reset; resetting would re-arm the bug. @ObservationIgnored private(set) var hasAttemptedInitialTab = false @@ -119,12 +105,6 @@ final class WorktreeTerminalState { @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient @ObservationIgnored @Dependency(\.analyticsClient) private var analyticsClient @ObservationIgnored @Dependency(\.continuousClock) private var clock - /// When a custom (hook / OSC 3008) notification last committed per surface. - /// Stored as a monotonic instant so the suppression window and the OSC-9 hold - /// share one clock source and can't desync on an NTP step / manual clock change. - private var lastCustomNotificationAt: [UUID: any InstantProtocol] = [:] - /// Agent OSC 9 notifications held to see if a custom notification supersedes them. - private var pendingAgentOSCNotifications: [UUID: Task] = [:] /// How long after a custom notification the agent's own OSC 9 is suppressed. /// Split from `oscHoldWindow` so tuning the suppression side cannot silently /// change the hold side. @@ -153,6 +133,52 @@ final class WorktreeTerminalState { var debugCustomNotificationTimestampCount: Int { lastCustomNotificationAt.count } var debugPendingOSCCount: Int { pendingAgentOSCNotifications.count } #endif + // MARK: - Terminal-kind state accessors (storage lives in `terminal`). + + var socketPath: String? { + get { terminal.socketPath } + set { terminal.socketPath = newValue } + } + private var surfaces: [UUID: GhosttySurfaceView] { + get { terminal.surfaces } + set { terminal.surfaces = newValue } + } + private var surfaceLaunchMetadata: [UUID: TerminalSurfaceStore.SurfaceLaunchMetadata] { + get { terminal.surfaceLaunchMetadata } + set { terminal.surfaceLaunchMetadata = newValue } + } + private var pendingExplicitSurfaceCloseIDs: Set { + get { terminal.pendingExplicitSurfaceCloseIDs } + set { terminal.pendingExplicitSurfaceCloseIDs = newValue } + } + // Every mutation schedules a coalesced row-projection emit so the TCA + // mirror of running scripts reconciles from this single source of truth + // (#573). All writes flow through this accessor; the store never mutates + // its copy directly. + private var blockingScripts: [TerminalTabID: BlockingScriptKind] { + get { terminal.blockingScripts } + set { + terminal.blockingScripts = newValue + scheduleRunningScriptsProjectionEmit() + } + } + private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] { + get { terminal.lastBlockingScriptTabByKind } + set { terminal.lastBlockingScriptTabByKind = newValue } + } + private var pendingSetupScript: Bool { + get { terminal.pendingSetupScript } + set { terminal.pendingSetupScript = newValue } + } + private var lastCustomNotificationAt: [UUID: any InstantProtocol] { + get { terminal.lastCustomNotificationAt } + set { terminal.lastCustomNotificationAt = newValue } + } + private var pendingAgentOSCNotifications: [UUID: Task] { + get { terminal.pendingAgentOSCNotifications } + set { terminal.pendingAgentOSCNotifications = newValue } + } + var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } } @@ -222,7 +248,7 @@ final class WorktreeTerminalState { self.runtime = runtime self.splitPreserveZoomOnNavigation = splitPreserveZoomOnNavigation ?? { runtime.splitPreserveZoomOnNavigation() } self.worktree = worktree - self.pendingSetupScript = runSetupScript + self.terminal = TerminalSurfaceStore(worktree: worktree, pendingSetupScript: runSetupScript) self.tabManager = TerminalTabManager() _repositorySettings = SharedReader( wrappedValue: RepositorySettings.default, @@ -353,7 +379,7 @@ final class WorktreeTerminalState { : GHOSTTY_SURFACE_CONTEXT_TAB let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() let title = "\(worktree.name) \(nextTabIndex())" - let setupInput = setupScriptInput(setupScript: setupScript) + let setupInput = terminal.setupScriptInput(setupScript: setupScript) let commandInput = initialInput.flatMap { BlockingScriptRunner.makeCommandInput(script: $0) } let resolvedInput: String? switch (setupInput, commandInput) { @@ -449,7 +475,7 @@ final class WorktreeTerminalState { host: host, script: script, remoteWorktreePath: worktree.workingDirectory.path(percentEncoded: false), - environment: blockingScriptEnvironment(for: kind) + environment: terminal.blockingScriptEnvironment(for: kind) ) else { reportBlockingScriptLaunchFailure(kind, "Failed to build remote \(kind.tabTitle) for worktree \(worktree.id)") @@ -461,7 +487,7 @@ final class WorktreeTerminalState { } else { let launch: BlockingScriptRunner.LaunchArtifacts do { - guard let prepared = try blockingScriptLaunch(script) else { + guard let prepared = try terminal.blockingScriptLaunch(script) else { reportBlockingScriptLaunchFailure( kind, "Failed to prepare \(kind.tabTitle) for worktree \(worktree.id): empty script") return nil @@ -505,13 +531,13 @@ final class WorktreeTerminalState { ) guard let tabId else { if let launchDirectory { - cleanupBlockingScriptLaunchDirectory(at: launchDirectory) + terminal.cleanupBlockingScriptLaunchDirectory(at: launchDirectory) } reportBlockingScriptLaunchFailure(kind, "Failed to create \(kind.tabTitle) tab for worktree \(worktree.id)") return nil } if let launchDirectory { - blockingScriptLaunchDirectories[tabId] = launchDirectory + terminal.blockingScriptLaunchDirectories[tabId] = launchDirectory } lastBlockingScriptTabByKind[kind] = tabId tabManager.updateDirty(tabId, isDirty: true) @@ -817,7 +843,7 @@ final class WorktreeTerminalState { func closeTab(_ tabId: TerminalTabID) { let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) - cleanupBlockingScriptLaunchDirectory(for: tabId) + terminal.cleanupBlockingScriptLaunchDirectory(for: tabId) // Clear lingering tab tracking for completed or non-blocking tabs. for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { lastBlockingScriptTabByKind.removeValue(forKey: kind) @@ -1049,7 +1075,7 @@ final class WorktreeTerminalState { for surfaceID in closingSurfaceIDs { discardSurfaceBookkeeping(for: surfaceID) } - cleanupBlockingScriptLaunchDirectories() + terminal.cleanupBlockingScriptLaunchDirectories() trees.removeAll() surfaceGenerationByTab.removeAll() focusedSurfaceIdByTab.removeAll() @@ -1410,44 +1436,6 @@ final class WorktreeTerminalState { } } - private func setupScriptInput(setupScript: String?) -> String? { - guard pendingSetupScript, let script = setupScript else { return nil } - return BlockingScriptRunner.makeCommandInput(script: script) - } - - private func cleanupBlockingScriptLaunchDirectory(for tabId: TerminalTabID) { - guard let directoryURL = blockingScriptLaunchDirectories.removeValue(forKey: tabId) else { return } - cleanupBlockingScriptLaunchDirectory(at: directoryURL) - } - - private func cleanupBlockingScriptLaunchDirectories() { - let directoryURLs = blockingScriptLaunchDirectories.values - blockingScriptLaunchDirectories.removeAll() - for directoryURL in directoryURLs { - cleanupBlockingScriptLaunchDirectory(at: directoryURL) - } - } - - private func cleanupBlockingScriptLaunchDirectory(at directoryURL: URL) { - do { - try FileManager.default.removeItem(at: directoryURL) - } catch { - blockingScriptLogger.warning( - "Failed to remove blocking script launch directory \(directoryURL.path(percentEncoded: false)): \(error)" - ) - } - } - - // The typed command stays shell-portable by invoking a generated wrapper file - // that reads the shell path from a sibling file and launches the user script, - // rather than serializing it into a shell-escaped `-c` string. - private func blockingScriptLaunch(_ script: String) throws -> BlockingScriptRunner.LaunchArtifacts? { - try BlockingScriptRunner.makeLaunch( - script: script, - shellPath: defaultShellPath() - ) - } - // Fires when the blocking command finishes. The shell stays alive // so the user can inspect output. Completion is reported here for // all exit codes. `handleBlockingScriptChildExited` covers the @@ -1510,67 +1498,6 @@ final class WorktreeTerminalState { } } - private func surfaceEnvironment(tabId: TerminalTabID, surfaceID: UUID) -> [String: String] { - var env = worktree.scriptEnvironment - let percentEncodingSet = CharacterSet.urlPathAllowed.subtracting(.init(charactersIn: "/")) - let repoPath = worktree.repositoryRootURL.path(percentEncoded: false) - env["SUPACODE_REPO_ID"] = percentEncode(repoPath, allowedCharacters: percentEncodingSet, label: "SUPACODE_REPO_ID") - env["SUPACODE_WORKTREE_ID"] = percentEncode( - worktree.id.rawValue, allowedCharacters: percentEncodingSet, label: "SUPACODE_WORKTREE_ID") - env["SUPACODE_TAB_ID"] = tabId.rawValue.uuidString - env["SUPACODE_SURFACE_ID"] = surfaceID.uuidString - if let socketPath { - env["SUPACODE_SOCKET_PATH"] = socketPath - } - // Mark blocking-script surfaces so the user's shell profile can skip its - // interactive init (prompt, plugins, banners) for these transient tabs. - if let blockingScriptKind = blockingScripts[tabId] { - env.merge(blockingScriptEnvironment(for: blockingScriptKind)) { _, new in new } - } - // Lock ZMX_DIR to the value the app's probe used so the shell can't - // re-export a different value from .zshrc / .zprofile and silently - // overflow `sockaddr_un.sun_path` past the probe's check. - env["ZMX_DIR"] = ZmxSocketBudget.socketDir() - // Prepend the bundled CLI binary directory to PATH so that `supacode` - // resolves to the CLI tool, not the app binary added by Ghostty. - if let cliBinDir = Bundle.main.resourceURL? - .appending(path: "bin", directoryHint: .isDirectory) - .path(percentEncoded: false) - { - let currentPath = ProcessInfo.processInfo.environment["PATH"] ?? "" - env["PATH"] = currentPath.isEmpty ? cliBinDir : "\(cliBinDir):\(currentPath)" - } - return env - } - - /// Blocking-script marker env vars for a kind, with scope resolved against - /// this worktree's settings. Shared by the local surface environment and the - /// remote runner export so both hosts expose the same signal. - private func blockingScriptEnvironment(for kind: BlockingScriptKind) -> [String: String] { - let scope = kind.scriptDefinitionID.flatMap(scriptScope(forDefinitionID:)) - return kind.surfaceEnvironmentVariables(scope: scope) - } - - /// Resolves whether a user-defined script is repo- or global-owned, mirroring - /// the repo-wins merge: an ID present in repo settings is `.repo`, otherwise - /// `.global`. Returns `nil` for a script that resolves to neither (e.g. a - /// since-deleted deeplink target). - private func scriptScope(forDefinitionID id: UUID) -> ScriptScope? { - if repositorySettings.scripts.contains(where: { $0.id == id }) { return .repo } - @Shared(.settingsFile) var settingsFile - if settingsFile.global.globalScripts.contains(where: { $0.id == id }) { return .global } - return nil - } - - private func percentEncode(_ value: String, allowedCharacters: CharacterSet, label: String) -> String { - guard let encoded = value.addingPercentEncoding(withAllowedCharacters: allowedCharacters) else { - terminalStateLogger.warning( - "Failed to percent-encode \(label): \(value). Downstream deeplinks using this value may be malformed.") - return value - } - return encoded - } - private func createSurface( tabId: TerminalTabID, command: String? = nil, @@ -1596,7 +1523,7 @@ final class WorktreeTerminalState { let surfaceID = resolvedID terminalStateLogger.info("createSurface: resolved=\(surfaceID)") let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) - let launch = resolveLaunch( + let launch = terminal.resolveLaunch( surfaceID: surfaceID, command: command, initialInput: initialInput, @@ -1615,7 +1542,7 @@ final class WorktreeTerminalState { workingDirectory: resolvedWorkingDirectory, command: launch.command, initialInput: launch.initialInput, - environmentVariables: surfaceEnvironment(tabId: tabId, surfaceID: surfaceID), + environmentVariables: terminal.surfaceEnvironment(tabId: tabId, surfaceID: surfaceID), commandWrapper: launch.commandWrapper, // Blocking-script runners (bypassZmx) emit their own OSC 133/7 and must // not get Ghostty's shell integration injected into the host shell. @@ -1625,7 +1552,7 @@ final class WorktreeTerminalState { ) wireSurfaceCallbacks(view: view, tabId: tabId) surfaces[view.id] = view - surfaceLaunchMetadata[view.id] = SurfaceLaunchMetadata(usesZmx: launch.usesZmx, context: context) + surfaceLaunchMetadata[view.id] = .init(usesZmx: launch.usesZmx, context: context) surfaceStates[view.id] = WorktreeSurfaceState() return view } @@ -1901,83 +1828,6 @@ final class WorktreeTerminalState { return String(scalars).trimmingCharacters(in: .whitespaces) } - struct ResolvedLaunch { - var command: String? - var initialInput: String? - var commandWrapper: [String] - var usesZmx: Bool - } - - /// Routes a surface through zmx so the underlying shell survives app quit. - /// - /// Interactive surfaces (no explicit `command`) keep `command` nil and inject - /// `zmx attach ` as a Ghostty `command-wrapper`, so Ghostty resolves and - /// integrates the user's real shell exactly as it would without zmx, with zmx - /// wrapping the whole resolved (login + integrated) argv. - /// - /// Explicit commands (scripts) instead wrap the command string itself, since - /// they don't want shell resolution / integration. `initialInput` is always - /// passed through; zmx is authoritative for attach-vs-create. - private func resolveLaunch( - surfaceID: UUID, - command: String?, - initialInput: String?, - bypassZmx: Bool - ) -> ResolvedLaunch { - if bypassZmx { - return ResolvedLaunch(command: command, initialInput: initialInput, commandWrapper: [], usesZmx: false) - } - let zmxExecutablePath = zmxClient.executableURL()?.path(percentEncoded: false) - // Remote worktree: a *local* zmx session wraps a reconnect loop around the - // SSH connection, and the remote reattaches its own zmx session when the - // host has zmx (host persistence). The surface command is always the - // reconnect-loop script (no command-wrapper, since Ghostty wraps the - // local argv, not the loop). When the caller has no explicit command, - // default to cd-into-the-remote-dir so a freshly created session lands in - // the project. - if let host = worktree.host { - @Shared(.settingsFile) var settingsFile - let hostPersistence = settingsFile.global.remoteSessionPersistenceEnabled - let launch = ZmxAttach.RemoteSurfaceLaunch( - host: host, - surfaceID: surfaceID, - userCommand: command, - defaultCommand: Self.remoteDefaultShellCommand( - remotePath: worktree.workingDirectory.path(percentEncoded: false)), - hostPersistenceEnabled: hostPersistence, - ) - return ResolvedLaunch( - command: ZmxAttach.buildRemoteCommand(launch, localZmxExecutablePath: zmxExecutablePath), - initialInput: initialInput, - commandWrapper: [], - usesZmx: zmxExecutablePath != nil, - ) - } - let resolved = ZmxAttach.resolveLaunch( - executablePath: zmxExecutablePath, - sessionID: ZmxSessionID.make(surfaceID: surfaceID), - command: command, - ) - return ResolvedLaunch( - command: resolved.command, - initialInput: initialInput, - commandWrapper: resolved.commandWrapper, - usesZmx: zmxExecutablePath != nil, - ) - } - - /// Connect default and reconnect fallback for a remote surface: `cd` into - /// the remote project dir, then exec a login shell. The `cd` failure is - /// swallowed so a stale path still drops the user into a usable shell. Nil - /// for an empty/root path falls back to a bare login shell. The path is - /// single-quoted for the login shell that re-parses the session command. - static func remoteDefaultShellCommand(remotePath: String) -> String? { - let trimmed = remotePath.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, trimmed != "/" else { return nil } - let quoted = "'" + trimmed.replacing("'", with: "'\\''") + "'" - return "cd \(quoted) 2>/dev/null; exec \"$SHELL\" -l" - } - private struct InheritedSurfaceConfig: Equatable { let workingDirectory: URL? let fontSize: Float32? @@ -2187,42 +2037,6 @@ final class WorktreeTerminalState { onSurfacesClosed?([surfaceID]) } - /// Tears down persistent zmx sessions for surfaces the user just closed. - /// `isBundled` (not `executableURL`) is the gate so sessions created on a - /// previous under-budget launch still tear down when this launch exceeds the - /// socket budget. One analytics event + one `withTaskGroup` per call. - /// `includeRemote` also tears down the host-side sessions of a remote - /// worktree; only explicit close paths set it, so a non-explicit end (clean - /// remote exit, deliberate host-side detach, or a reconnect abort) spares - /// the host session. The remote kill is unconditional on explicit close (no - /// per-surface persistence gate): a host session may exist from an earlier - /// launch regardless of the current toggle, and the kill invocation is a - /// silent no-op when nothing exists. - private func killZmxSessions(forSurfaceIDs surfaceIDs: [UUID], includeRemote: Bool = false) { - guard !surfaceIDs.isEmpty else { return } - let killLocal = zmxClient.isBundled() - let host = includeRemote ? worktree.host : nil - guard killLocal || host != nil else { return } - let sessionIDs = surfaceIDs.map(ZmxSessionID.make(surfaceID:)) - let client = zmxClient - analyticsClient.capture( - "terminal_persistence_session_killed", - [ - "reason": "user_close", "count": killLocal ? sessionIDs.count : 0, - "remote_count": host == nil ? 0 : sessionIDs.count, - ] - ) - Task.detached { - await withTaskGroup(of: Void.self) { group in - for id in sessionIDs { - group.addTask { - await client.killSurfaceSessions(sessionID: id, remoteHost: host, killLocal: killLocal) - } - } - } - } - } - private func removeTree(for tabId: TerminalTabID) { guard let tree = trees.removeValue(forKey: tabId) else { return } surfaceGenerationByTab.removeValue(forKey: tabId) @@ -2234,7 +2048,7 @@ final class WorktreeTerminalState { cleanupSurfaceState(for: surface.id) } } - killZmxSessions(forSurfaceIDs: leafIDs, includeRemote: true) + terminal.killZmxSessions(forSurfaceIDs: leafIDs, includeRemote: true) focusedSurfaceIdByTab.removeValue(forKey: tabId) if lastTabProjections.removeValue(forKey: tabId) != nil { onTabRemoved?(tabId) @@ -2618,7 +2432,7 @@ final class WorktreeTerminalState { view.closeSurface() cleanupSurfaceState(for: view.id) if killZmxSession { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) + terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) } return } @@ -2626,7 +2440,7 @@ final class WorktreeTerminalState { view.closeSurface() cleanupSurfaceState(for: view.id) if killZmxSession { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) + terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) } return } @@ -2638,12 +2452,12 @@ final class WorktreeTerminalState { view.closeSurface() cleanupSurfaceState(for: view.id) if killZmxSession { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) + terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) } if newTree.isEmpty { trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) - cleanupBlockingScriptLaunchDirectory(for: tabId) + terminal.cleanupBlockingScriptLaunchDirectory(for: tabId) tabManager.closeTab(tabId) updateShouldHideTabBar() if let kind = blockingScripts.removeValue(forKey: tabId) { diff --git a/supacodeTests/RemoteRepositorySidebarTests.swift b/supacodeTests/RemoteRepositorySidebarTests.swift index 5b8b8da42..be68ef8ad 100644 --- a/supacodeTests/RemoteRepositorySidebarTests.swift +++ b/supacodeTests/RemoteRepositorySidebarTests.swift @@ -397,21 +397,21 @@ struct RemoteDisconnectCurationTests { struct RemoteDefaultShellCommandTests { @Test func buildsCdIntoRemotePathThenExecLoginShell() { #expect( - WorktreeTerminalState.remoteDefaultShellCommand(remotePath: "/home/me/proj") + TerminalSurfaceStore.remoteDefaultShellCommand(remotePath: "/home/me/proj") == "cd '/home/me/proj' 2>/dev/null; exec \"$SHELL\" -l" ) } @Test func escapesSingleQuotesInRemotePath() { #expect( - WorktreeTerminalState.remoteDefaultShellCommand(remotePath: "/home/o'brien/proj") + TerminalSurfaceStore.remoteDefaultShellCommand(remotePath: "/home/o'brien/proj") == "cd '/home/o'\\''brien/proj' 2>/dev/null; exec \"$SHELL\" -l" ) } @Test func nilForRootOrEmptyPath() { - #expect(WorktreeTerminalState.remoteDefaultShellCommand(remotePath: "/") == nil) - #expect(WorktreeTerminalState.remoteDefaultShellCommand(remotePath: " ") == nil) + #expect(TerminalSurfaceStore.remoteDefaultShellCommand(remotePath: "/") == nil) + #expect(TerminalSurfaceStore.remoteDefaultShellCommand(remotePath: " ") == nil) } } From d5af4ac8b9d500fbdada96066a5d6f619cf690e1 Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Fri, 10 Jul 2026 19:59:18 -0400 Subject: [PATCH 07/13] Kind-tag the persisted layout leaf with lossy unknown-kind decode TerminalLayoutSnapshot's leaf becomes enum SurfaceSnapshot with a terminal case carrying the old payload (now TerminalSurfaceSnapshot). The terminal case still encodes the legacy flat shape, so layouts round-trip with older builds; a future kind writes a `kind` tag this build rejects, and the now-lossy tabs decode drops just the affected tab instead of the whole layout. Restore and the leaf walkers dispatch on the kind exhaustively. Because the lossy decode is positional, the persisted selectedTabIndex stays in the original tabs array's coordinates, so dropping an unknown-kind tab that precedes the selection would restore the wrong tab. Decode therefore reports dropped indices (a new decodeLossyArrayWithDroppedIndices helper alongside decodeLossyArrayIfPresent) and shifts the index left by the number of dropped tabs before it. --- SupacodeSettingsShared/Models/Lossy.swift | 24 +++ supacode/Domain/SplitDirection.swift | 4 +- .../Models/TerminalLayoutSnapshot.swift | 73 +++++++- .../Models/WorktreeTerminalState.swift | 24 ++- supacodeTests/AgentPresenceFeatureTests.swift | 2 +- .../LayoutsIncrementalWriterTests.swift | 4 +- .../RepositoriesFeatureSidebarTests.swift | 10 +- .../TerminalLayoutSnapshotTests.swift | 161 +++++++++++++++--- ...erminalManagerLayoutPersistenceTests.swift | 2 +- .../WorktreeTerminalManagerTests.swift | 18 +- 10 files changed, 267 insertions(+), 55 deletions(-) diff --git a/SupacodeSettingsShared/Models/Lossy.swift b/SupacodeSettingsShared/Models/Lossy.swift index 0fa042f2d..8e79c12cc 100644 --- a/SupacodeSettingsShared/Models/Lossy.swift +++ b/SupacodeSettingsShared/Models/Lossy.swift @@ -33,4 +33,28 @@ extension KeyedDecodingContainer { } return wrappers.compactMap(\.value) } + + /// Like `decodeLossyArrayIfPresent`, but also reports the ORIGINAL indices of + /// dropped elements so callers can remap positional fields (e.g. a persisted + /// selected-index) into the surviving array's coordinate space. + public nonisolated func decodeLossyArrayWithDroppedIndices( + _ type: [T].Type = [T].self, + forKey key: Key + ) -> (elements: [T], droppedIndices: [Int])? { + guard contains(key) else { return nil } + guard let wrappers = try? decode([Lossy].self, forKey: key) else { + lossyLogger.warning("Could not decode lossy array at '\(key.stringValue)'; returning empty.") + return ([], []) + } + var elements: [T] = [] + var droppedIndices: [Int] = [] + for (index, wrapper) in wrappers.enumerated() { + if let value = wrapper.value { + elements.append(value) + } else { + droppedIndices.append(index) + } + } + return (elements, droppedIndices) + } } diff --git a/supacode/Domain/SplitDirection.swift b/supacode/Domain/SplitDirection.swift index eed1d971e..d71488b58 100644 --- a/supacode/Domain/SplitDirection.swift +++ b/supacode/Domain/SplitDirection.swift @@ -1,6 +1,6 @@ /// Direction for terminal surface splits. /// Keep in sync with `CLISplitDirection` in `supacode-cli/Helpers/CLISplitDirection.swift`. -enum SplitDirection: Equatable, Sendable { +nonisolated enum SplitDirection: Equatable, Sendable { case horizontal case vertical @@ -69,7 +69,7 @@ enum TerminalSplitMenuDirection: Equatable, Sendable, CaseIterable { // Explicit Codable using raw strings to preserve backward compatibility // with the previous `String`-backed enum encoding. -extension SplitDirection: Codable { +nonisolated extension SplitDirection: Codable { init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() let value = try container.decode(String.self) diff --git a/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift b/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift index 288652327..8bd321018 100644 --- a/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift +++ b/supacode/Features/Terminal/Models/TerminalLayoutSnapshot.swift @@ -1,10 +1,32 @@ import Foundation import SupacodeSettingsShared -struct TerminalLayoutSnapshot: Codable, Equatable, Sendable { +nonisolated struct TerminalLayoutSnapshot: Codable, Equatable, Sendable { let tabs: [TabSnapshot] let selectedTabIndex: Int + init(tabs: [TabSnapshot], selectedTabIndex: Int) { + self.tabs = tabs + self.selectedTabIndex = selectedTabIndex + } + + private enum CodingKeys: String, CodingKey { + case tabs, selectedTabIndex + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Lossy so one undecodable tab (e.g. a leaf kind this build doesn't know) + // drops that tab, not the whole layout. Restore already clamps + // `selectedTabIndex` and skips empty snapshots. + let decoded = container.decodeLossyArrayWithDroppedIndices([TabSnapshot].self, forKey: .tabs) ?? ([], []) + tabs = decoded.elements + let rawIndex = try container.decodeIfPresent(Int.self, forKey: .selectedTabIndex) ?? 0 + // The persisted index is in the ORIGINAL array's coordinates; every dropped + // tab before it shifts the surviving selection left by one. + selectedTabIndex = rawIndex - decoded.droppedIndices.count(where: { $0 < rawIndex }) + } + struct TabSnapshot: Codable, Equatable, Sendable { let id: UUID? let title: String @@ -62,7 +84,50 @@ struct TerminalLayoutSnapshot: Codable, Equatable, Sendable { let right: LayoutNode } - struct SurfaceSnapshot: Codable, Equatable, Sendable { + /// Kind-tagged persisted leaf. The terminal case encodes in the legacy flat + /// shape (no `kind` tag) so layouts written by this build still decode on + /// older builds; a future kind encodes a `kind` discriminator that this + /// build's decoder rejects, and the lossy `tabs` decode above drops just the + /// affected tab. + enum SurfaceSnapshot: Codable, Equatable, Sendable { + case terminal(TerminalSurfaceSnapshot) + + /// Convenience mirroring the terminal payload's initializer. + static func terminal( + id: UUID?, workingDirectory: String?, agents: [SurfaceAgentRecord]? = nil + ) -> SurfaceSnapshot { + .terminal(TerminalSurfaceSnapshot(id: id, workingDirectory: workingDirectory, agents: agents)) + } + + private enum CodingKeys: String, CodingKey { + case kind + } + + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decodeIfPresent(String.self, forKey: .kind) + switch kind { + case nil, "terminal": + self = .terminal(try TerminalSurfaceSnapshot(from: decoder)) + default: + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unknown surface snapshot kind: \(kind ?? "nil")" + ) + ) + } + } + + func encode(to encoder: any Encoder) throws { + switch self { + case .terminal(let terminal): + try terminal.encode(to: encoder) + } + } + } + + struct TerminalSurfaceSnapshot: Codable, Equatable, Sendable { let id: UUID? let workingDirectory: String? /// Agent presence captured at quit, restored on next launch after an @@ -124,7 +189,7 @@ nonisolated extension TerminalLayoutSnapshot.LayoutNode { /// reaper to know which zmx sessions are still "owned" by persisted layouts. var leafSurfaceIDs: [UUID] { switch self { - case .leaf(let surface): + case .leaf(.terminal(let surface)): return surface.id.map { [$0] } ?? [] case .split(let split): return split.left.leafSurfaceIDs + split.right.leafSurfaceIDs @@ -152,7 +217,7 @@ nonisolated extension TerminalLayoutSnapshot { nonisolated extension TerminalLayoutSnapshot.LayoutNode { fileprivate func leafAgents() -> [(surfaceID: UUID, records: [TerminalLayoutSnapshot.SurfaceAgentRecord])] { switch self { - case .leaf(let surface): + case .leaf(.terminal(let surface)): guard let id = surface.id, let agents = surface.agents, !agents.isEmpty else { return [] } return [(id, agents)] case .split(let split): diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 1c6a84657..f133ac886 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -1252,7 +1252,7 @@ final class WorktreeTerminalState { switch view.content { case .terminal(let surface): return .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + .terminal( id: surface.id, workingDirectory: surface.bridge.state.pwd, agents: agentsBySurface[surface.id] @@ -1286,8 +1286,12 @@ final class WorktreeTerminalState { pendingSetupScript = false for (index, tabSnapshot) in snapshot.tabs.enumerated() { - let firstLeafPwd = tabSnapshot.layout.firstLeaf.workingDirectory - let workingDir = firstLeafPwd.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } + // Kind dispatch on the persisted leaf; a new snapshot kind adds a case. + let firstLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot + switch tabSnapshot.layout.firstLeaf { + case .terminal(let leaf): firstLeaf = leaf + } + let workingDir = firstLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } let context: ghostty_surface_context_e = index == 0 ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB let tabId = tabManager.createTab( @@ -1306,7 +1310,7 @@ final class WorktreeTerminalState { workingDirectoryOverride: workingDir, inheritingFromSurfaceId: nil, context: context, - surfaceID: tabSnapshot.layout.firstLeaf.id, + surfaceID: firstLeaf.id, ) let tree = SplitTree(view: surface) setTree(tree, for: tabId) @@ -1369,9 +1373,13 @@ final class WorktreeTerminalState { ) { guard case .split(let split) = node else { return } - // Create the right child by splitting the anchor. - let rightPwd = split.right.firstLeaf.workingDirectory - let rightWorkingDir = rightPwd.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } + // Create the right child by splitting the anchor. Kind dispatch on the + // persisted leaf; a new snapshot kind adds a case. + let rightLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot + switch split.right.firstLeaf { + case .terminal(let leaf): rightLeaf = leaf + } + let rightWorkingDir = rightLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } let direction: SplitTree.NewDirection = split.direction == .horizontal ? .right : .down @@ -1382,7 +1390,7 @@ final class WorktreeTerminalState { ratio: split.ratio, workingDirectory: rightWorkingDir, tabId: tabId, - surfaceID: split.right.firstLeaf.id, + surfaceID: rightLeaf.id, ) else { layoutLogger.warning("Skipping subtree restoration for tab \(tabId.rawValue)") diff --git a/supacodeTests/AgentPresenceFeatureTests.swift b/supacodeTests/AgentPresenceFeatureTests.swift index 32e2dfd95..e3a8c425c 100644 --- a/supacodeTests/AgentPresenceFeatureTests.swift +++ b/supacodeTests/AgentPresenceFeatureTests.swift @@ -922,7 +922,7 @@ struct AgentPresenceFeatureTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal( id: surface.id, workingDirectory: nil, agents: surface.agents diff --git a/supacodeTests/LayoutsIncrementalWriterTests.swift b/supacodeTests/LayoutsIncrementalWriterTests.swift index 333f11821..8058e9379 100644 --- a/supacodeTests/LayoutsIncrementalWriterTests.swift +++ b/supacodeTests/LayoutsIncrementalWriterTests.swift @@ -17,7 +17,7 @@ struct LayoutsIncrementalWriterTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: dir) + TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: dir) ), focusedLeafIndex: 0 ) @@ -72,7 +72,7 @@ struct LayoutsIncrementalWriterTests { let dict = readDict(storage, url) #expect(dict["w2"] != nil) let leaf = dict["w1"]?.tabs.first?.layout - if case .leaf(let surface) = leaf { + if case .leaf(.terminal(let surface)) = leaf { #expect(surface.workingDirectory == "/new") } else { Issue.record("Expected a leaf layout for w1") diff --git a/supacodeTests/RepositoriesFeatureSidebarTests.swift b/supacodeTests/RepositoriesFeatureSidebarTests.swift index 529e0dbf5..b09408032 100644 --- a/supacodeTests/RepositoriesFeatureSidebarTests.swift +++ b/supacodeTests/RepositoriesFeatureSidebarTests.swift @@ -248,8 +248,8 @@ struct RepositoriesFeatureSidebarTests { TerminalLayoutSnapshot.SplitSnapshot( direction: .horizontal, ratio: 0.5, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: surfaceA, workingDirectory: nil)), - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: surfaceB, workingDirectory: nil)) + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: surfaceA, workingDirectory: nil)), + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: surfaceB, workingDirectory: nil)) ) ), focusedLeafIndex: 0 @@ -308,7 +308,7 @@ struct RepositoriesFeatureSidebarTests { customTitle: nil, icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: surfaceA, workingDirectory: nil)), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: surfaceA, workingDirectory: nil)), focusedLeafIndex: 0 ) ], @@ -364,7 +364,7 @@ struct RepositoriesFeatureSidebarTests { customTitle: nil, icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: staleSurface, workingDirectory: nil)), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: staleSurface, workingDirectory: nil)), focusedLeafIndex: 0 ) ], @@ -423,7 +423,7 @@ struct RepositoriesFeatureSidebarTests { customTitle: nil, icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: staleSurface, workingDirectory: nil)), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: staleSurface, workingDirectory: nil)), focusedLeafIndex: 0 ) ], diff --git a/supacodeTests/TerminalLayoutSnapshotTests.swift b/supacodeTests/TerminalLayoutSnapshotTests.swift index e3e8ea937..03e3d4df1 100644 --- a/supacodeTests/TerminalLayoutSnapshotTests.swift +++ b/supacodeTests/TerminalLayoutSnapshotTests.swift @@ -63,13 +63,14 @@ struct TerminalLayoutSnapshotTests { TerminalLayoutSnapshot.SplitSnapshot( direction: .horizontal, ratio: 0.7, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/Users/test/project")), + left: .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/Users/test/project")), right: .split( TerminalLayoutSnapshot.SplitSnapshot( direction: .vertical, ratio: 0.4, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/tmp")), - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)) + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/tmp")), + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: nil)) ) ) ) @@ -82,7 +83,7 @@ struct TerminalLayoutSnapshotTests { customTitle: nil, icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/Users/test")), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/Users/test")), focusedLeafIndex: 0 ), ], @@ -101,11 +102,11 @@ struct TerminalLayoutSnapshotTests { TerminalLayoutSnapshot.SplitSnapshot( direction: .horizontal, ratio: 0.5, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/first")), - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/second")) + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/first")), + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/second")) ) ) - #expect(node.firstLeaf.workingDirectory == "/first") + #expect(node.firstLeaf.terminal.workingDirectory == "/first") } @Test func leafCountCountsAllLeaves() { @@ -113,13 +114,13 @@ struct TerminalLayoutSnapshotTests { TerminalLayoutSnapshot.SplitSnapshot( direction: .horizontal, ratio: 0.5, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)), + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: nil)), right: .split( TerminalLayoutSnapshot.SplitSnapshot( direction: .vertical, ratio: 0.5, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)), - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)) + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: nil)), + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: nil)) ) ) ) @@ -134,7 +135,7 @@ struct TerminalLayoutSnapshotTests { customTitle: "my-tab", icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: UUID(), workingDirectory: nil)), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: UUID(), workingDirectory: nil)), focusedLeafIndex: 0 ) let snapshot = TerminalLayoutSnapshot(tabs: [tabSnapshot], selectedTabIndex: 0) @@ -163,7 +164,7 @@ struct TerminalLayoutSnapshotTests { customTitle: nil, icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/home")), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/home")), focusedLeafIndex: 0 ) ], @@ -173,7 +174,7 @@ struct TerminalLayoutSnapshotTests { let data = try JSONEncoder().encode(snapshot) let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: data) #expect(decoded.tabs.count == 1) - #expect(decoded.tabs[0].layout.firstLeaf.workingDirectory == "/home") + #expect(decoded.tabs[0].layout.firstLeaf.terminal.workingDirectory == "/home") #expect(decoded.tabs[0].layout.leafCount == 1) } @@ -193,8 +194,8 @@ struct TerminalLayoutSnapshotTests { TerminalLayoutSnapshot.SplitSnapshot( direction: .horizontal, ratio: 0.5, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: leftSurface, workingDirectory: nil)), - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: rightSurface, workingDirectory: nil)) + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: leftSurface, workingDirectory: nil)), + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: rightSurface, workingDirectory: nil)) ) ), focusedLeafIndex: 0 @@ -205,7 +206,7 @@ struct TerminalLayoutSnapshotTests { customTitle: nil, icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: secondTabSurface, workingDirectory: nil)), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: secondTabSurface, workingDirectory: nil)), focusedLeafIndex: 0 ), ], @@ -229,7 +230,7 @@ struct TerminalLayoutSnapshotTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal( id: surfaceID, workingDirectory: "/repo", agents: [ @@ -256,7 +257,7 @@ struct TerminalLayoutSnapshotTests { let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: data) #expect(decoded == snapshot) - let leaf = decoded.tabs[0].layout.firstLeaf + let leaf = decoded.tabs[0].layout.firstLeaf.terminal #expect(leaf.agents?.count == 2) #expect(leaf.agents?[0].pids == [12345, 67890]) #expect(leaf.agents?[1].activity == "idle") @@ -285,7 +286,7 @@ struct TerminalLayoutSnapshotTests { TerminalLayoutSnapshot.self, from: Data(json.utf8) ) - let leaf = decoded.tabs[0].layout.firstLeaf + let leaf = decoded.tabs[0].layout.firstLeaf.terminal #expect(leaf.agents == nil) #expect(leaf.id == UUID(uuidString: "00000000-0000-0000-0000-000000000002")) } @@ -318,7 +319,7 @@ struct TerminalLayoutSnapshotTests { TerminalLayoutSnapshot.self, from: Data(json.utf8) ) - let leaf = decoded.tabs[0].layout.firstLeaf + let leaf = decoded.tabs[0].layout.firstLeaf.terminal #expect(leaf.agents == nil) } @@ -338,7 +339,7 @@ struct TerminalLayoutSnapshotTests { direction: .horizontal, ratio: 0.5, left: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal( id: surfaceA, workingDirectory: nil, agents: [ @@ -349,7 +350,7 @@ struct TerminalLayoutSnapshotTests { ) ), right: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot(id: surfaceB, workingDirectory: nil, agents: nil) + TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: surfaceB, workingDirectory: nil, agents: nil) ) ) ), @@ -381,8 +382,8 @@ struct TerminalLayoutSnapshotTests { TerminalLayoutSnapshot.SplitSnapshot( direction: .horizontal, ratio: 0.5, - left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: nil)), - right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: real, workingDirectory: nil)) + left: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: nil)), + right: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: real, workingDirectory: nil)) ) ), focusedLeafIndex: 0 @@ -394,3 +395,115 @@ struct TerminalLayoutSnapshotTests { #expect(snapshot.allSurfaceIDs == [real]) } } + +extension TerminalLayoutSnapshot.SurfaceSnapshot { + /// Test convenience: the terminal payload (every leaf in these fixtures is terminal). + fileprivate var terminal: TerminalLayoutSnapshot.TerminalSurfaceSnapshot { + switch self { + case .terminal(let terminal): terminal + } + } +} + +struct SurfaceSnapshotKindTests { + /// A leaf kind this build doesn't know (written by a future build) must drop + /// only the tab containing it; sibling tabs survive. + @Test func unknownLeafKindDropsOnlyThatTab() throws { + let json = """ + { + "tabs": [ + { + "title": "future", + "layout": { "leaf": { "_0": { "kind": "browser", "url": "https://example.com" } } }, + "focusedLeafIndex": 0 + }, + { + "title": "terminal", + "layout": { "leaf": { "_0": { "id": null, "workingDirectory": "/home" } } }, + "focusedLeafIndex": 0 + } + ], + "selectedTabIndex": 0 + } + """ + let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: Data(json.utf8)) + #expect(decoded.tabs.count == 1) + #expect(decoded.tabs.first?.title == "terminal") + } + + /// Dropping an unknown-kind tab must remap `selectedTabIndex` into the + /// surviving array's coordinates, or restore selects the wrong tab. + @Test func unknownLeafKindBeforeSelectionRemapsSelectedIndex() throws { + let json = """ + { + "tabs": [ + { + "title": "future", + "layout": { "leaf": { "_0": { "kind": "browser" } } }, + "focusedLeafIndex": 0 + }, + { + "title": "selected", + "layout": { "leaf": { "_0": { "id": null, "workingDirectory": null } } }, + "focusedLeafIndex": 0 + }, + { + "title": "other", + "layout": { "leaf": { "_0": { "id": null, "workingDirectory": null } } }, + "focusedLeafIndex": 0 + } + ], + "selectedTabIndex": 1 + } + """ + let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: Data(json.utf8)) + #expect(decoded.tabs.map(\.title) == ["selected", "other"]) + #expect(decoded.selectedTabIndex == 0) + } + + /// A dropped tab AFTER the selection must not shift it. + @Test func unknownLeafKindAfterSelectionKeepsSelectedIndex() throws { + let json = """ + { + "tabs": [ + { + "title": "selected", + "layout": { "leaf": { "_0": { "id": null, "workingDirectory": null } } }, + "focusedLeafIndex": 0 + }, + { + "title": "future", + "layout": { "leaf": { "_0": { "kind": "browser" } } }, + "focusedLeafIndex": 0 + } + ], + "selectedTabIndex": 0 + } + """ + let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: Data(json.utf8)) + #expect(decoded.tabs.map(\.title) == ["selected"]) + #expect(decoded.selectedTabIndex == 0) + } + + /// The terminal case encodes in the legacy flat shape (no `kind` tag) so + /// layouts written by this build still decode on older builds. + @Test func terminalLeafEncodesWithoutKindTag() throws { + let snapshot = TerminalLayoutSnapshot( + tabs: [ + TerminalLayoutSnapshot.TabSnapshot( + id: nil, + title: "tab", + customTitle: nil, + icon: nil, + tintColor: nil, + layout: .leaf(.terminal(id: nil, workingDirectory: "/home")), + focusedLeafIndex: 0 + ) + ], + selectedTabIndex: 0 + ) + let data = try JSONEncoder().encode(snapshot) + let text = String(bytes: data, encoding: .utf8) ?? "" + #expect(!text.contains("\"kind\"")) + } +} diff --git a/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift b/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift index d9caffa38..8590ccea4 100644 --- a/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift +++ b/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift @@ -240,7 +240,7 @@ struct LayoutPersistenceManagerTests { await waitUntil { readDict(harness)[worktree.id.rawValue]?.tabs.first?.layout != nil } let leaf = readDict(harness)[worktree.id.rawValue]?.tabs.first?.layout - guard case .leaf(let persisted) = leaf else { + guard case .leaf(.terminal(let persisted)) = leaf else { Issue.record("Expected a leaf layout") return } diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeTerminalManagerTests.swift index 81725cd53..cd96d4b04 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeTerminalManagerTests.swift @@ -1013,10 +1013,12 @@ struct WorktreeTerminalManagerTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( - id: surfaceID, - workingDirectory: "/tmp/repo/wt-1", - agents: agents + .terminal( + TerminalLayoutSnapshot.TerminalSurfaceSnapshot( + id: surfaceID, + workingDirectory: "/tmp/repo/wt-1", + agents: agents + ) ) ), focusedLeafIndex: 0 @@ -1801,7 +1803,7 @@ struct WorktreeTerminalManagerTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal( id: knownSurfaceID, workingDirectory: "/tmp/repo/wt-1" ) @@ -3257,7 +3259,7 @@ struct WorktreeTerminalManagerTests { customTitle: " ", icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/tmp/repo/wt-1")), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/tmp/repo/wt-1")), focusedLeafIndex: 0 ) ], @@ -3284,7 +3286,7 @@ struct WorktreeTerminalManagerTests { customTitle: "foo", icon: nil, tintColor: nil, - layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot(id: nil, workingDirectory: "/tmp/repo/wt-1")), + layout: .leaf(TerminalLayoutSnapshot.SurfaceSnapshot.terminal(id: nil, workingDirectory: "/tmp/repo/wt-1")), focusedLeafIndex: 0 ) ], @@ -3306,7 +3308,7 @@ struct WorktreeTerminalManagerTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal( id: nil, workingDirectory: "/tmp/repo/wt-1" ) From 58b6c1fe228aa9ba6112bb7efe7b6e6d251a49cd Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Fri, 10 Jul 2026 20:12:21 -0400 Subject: [PATCH 08/13] Rename the surface layer's load-bearing types to surface-neutral names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical rename, no behavior change: TerminalClient → SurfaceClient (with the terminalClient dependency and the .terminalEvent action following), WorktreeTerminalManager → WorktreeSurfaceManager, and WorktreeTerminalState → WorktreeSurfaceState — the old per-surface WorktreeSurfaceState observable becomes SurfaceIndicatorState to free the name. Docs updated to match. Tab-layer names (TerminalTabID, TerminalTabManager, TerminalLayoutSnapshot, the tab/split views) stay terminal-flavored until tab creation is kind-dispatched end to end; --- Project.swift | 2 +- supacode/App/ContentView.swift | 20 +- supacode/App/WindowTitle.swift | 10 +- supacode/App/supacodeApp.swift | 84 +- ...rminalClient.swift => SurfaceClient.swift} | 68 +- .../App/Models/WorktreeMenuSnapshot.swift | 4 +- .../Features/App/Reducer/AppFeature.swift | 138 +- .../Models/ToolbarNotificationGroup.swift | 2 +- .../Reducer/RepositoriesFeature+Sidebar.swift | 2 +- .../Views/SidebarHighlightSectionsView.swift | 8 +- .../Repositories/Views/SidebarItemsView.swift | 36 +- .../Repositories/Views/SidebarListView.swift | 20 +- .../Repositories/Views/SidebarView.swift | 4 +- .../Views/WorktreeDetailView.swift | 30 +- .../Views/WorktreeStatusInspector.swift | 10 +- ...ger.swift => WorktreeSurfaceManager.swift} | 52 +- .../Models/SurfaceDragCoordinator.swift | 2 +- .../Models/SurfaceIndicatorState.swift | 10 + .../Models/TerminalSurfaceCommand.swift | 6 +- .../Models/TerminalSurfaceStore.swift | 2 +- .../Models/WorktreeSurfaceState.swift | 2597 +++++++++++++++- .../Models/WorktreeTerminalState.swift | 2599 ----------------- .../Terminal/Reducer/TerminalTabFeature.swift | 2 +- .../Terminal/Reducer/TerminalsFeature.swift | 2 +- .../TabBar/Views/TerminalTabBarView.swift | 2 +- .../TabBar/Views/TerminalTabsRowView.swift | 4 +- .../TabBar/Views/TerminalTabsView.swift | 2 +- .../Views/TerminalSplitTreeView.swift | 12 +- .../Views/WorktreeTerminalTabsView.swift | 6 +- .../Ghostty/GhosttySurfaceBridge.swift | 2 +- supacodeTests/AgentBusyStateTests.swift | 14 +- supacodeTests/AgentPresence+TestHelpers.swift | 12 +- supacodeTests/AgentPresenceOSCTests.swift | 32 +- .../AppFeatureArchivedSelectionTests.swift | 42 +- supacodeTests/AppFeatureCommandAckTests.swift | 24 +- .../AppFeatureCommandPaletteTests.swift | 52 +- supacodeTests/AppFeatureDeeplinkTests.swift | 154 +- .../AppFeatureDefaultEditorTests.swift | 2 +- .../AppFeatureJumpToLatestUnreadTests.swift | 26 +- .../AppFeatureOpenWorktreeTests.swift | 6 +- supacodeTests/AppFeatureRunScriptTests.swift | 44 +- .../AppFeatureSelectTerminalTabTests.swift | 8 +- .../AppFeatureSplitTerminalTests.swift | 8 +- .../AppFeatureSystemNotificationTests.swift | 28 +- .../AppFeatureTerminalSetupScriptTests.swift | 22 +- .../RepositoriesFeatureSidebarTests.swift | 2 +- supacodeTests/SplitTreeTests.swift | 6 +- .../TerminalRenderingPolicyTests.swift | 12 +- supacodeTests/WindowTitleTests.swift | 36 +- ...urfaceManagerLayoutPersistenceTests.swift} | 6 +- ...> WorktreeSurfaceManagerReaperTests.swift} | 8 +- ...wift => WorktreeSurfaceManagerTests.swift} | 252 +- ...ft => WorktreeSurfaceStateDropTests.swift} | 6 +- 53 files changed, 3270 insertions(+), 3270 deletions(-) rename supacode/Clients/Terminal/{TerminalClient.swift => SurfaceClient.swift} (68%) rename supacode/Features/Terminal/BusinessLogic/{WorktreeTerminalManager.swift => WorktreeSurfaceManager.swift} (97%) create mode 100644 supacode/Features/Terminal/Models/SurfaceIndicatorState.swift delete mode 100644 supacode/Features/Terminal/Models/WorktreeTerminalState.swift rename supacodeTests/{WorktreeTerminalManagerLayoutPersistenceTests.swift => WorktreeSurfaceManagerLayoutPersistenceTests.swift} (98%) rename supacodeTests/{WorktreeTerminalManagerReaperTests.swift => WorktreeSurfaceManagerReaperTests.swift} (94%) rename supacodeTests/{WorktreeTerminalManagerTests.swift => WorktreeSurfaceManagerTests.swift} (93%) rename supacodeTests/{WorktreeTerminalStateDropTests.swift => WorktreeSurfaceStateDropTests.swift} (95%) diff --git a/Project.swift b/Project.swift index 67e62f61d..e788751df 100644 --- a/Project.swift +++ b/Project.swift @@ -108,7 +108,7 @@ let terminalTestSources: [Path] = [ "supacodeTests/Ghostty*.swift", "supacodeTests/Layouts*.swift", "supacodeTests/SplitTree*.swift", - "supacodeTests/WorktreeTerminalManager*.swift", + "supacodeTests/WorktreeSurfaceManager*.swift", "supacodeTests/Zmx*.swift", ] diff --git a/supacode/App/ContentView.swift b/supacode/App/ContentView.swift index b08522730..c900b6edb 100644 --- a/supacode/App/ContentView.swift +++ b/supacode/App/ContentView.swift @@ -17,15 +17,15 @@ import UniformTypeIdentifiers struct ContentView: View { @Bindable var store: StoreOf @Bindable var repositoriesStore: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager @Environment(\.scenePhase) private var scenePhase @Environment(GhosttyShortcutManager.self) private var ghosttyShortcuts @State private var leftSidebarVisibility: NavigationSplitViewVisibility = .all - init(store: StoreOf, terminalManager: WorktreeTerminalManager) { + init(store: StoreOf, surfaceManager: WorktreeSurfaceManager) { self.store = store repositoriesStore = store.scope(state: \.repositories, action: \.repositories) - self.terminalManager = terminalManager + self.surfaceManager = surfaceManager } var body: some View { @@ -33,13 +33,13 @@ struct ContentView: View { let _ = contentRenderLogger.info("ContentView.body re-rendered") #endif return NavigationSplitView(columnVisibility: $leftSidebarVisibility) { - SidebarView(store: repositoriesStore, terminalManager: terminalManager) + SidebarView(store: repositoriesStore, surfaceManager: surfaceManager) .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 320) .safeAreaInset(edge: .bottom, spacing: 0) { SidebarBottomCardView(store: store) } } detail: { - WorktreeDetailView(store: store, terminalManager: terminalManager) + WorktreeDetailView(store: store, surfaceManager: surfaceManager) } .navigationSplitViewStyle(.automatic) .disabled(!repositoriesStore.isInitialLoadComplete) @@ -141,12 +141,12 @@ struct ContentView: View { ) } .background(WindowTabbingDisabler()) - .background(WindowTintBackdrop(runtime: terminalManager.ghosttyRuntime)) - .background(WindowChromeObserver(runtime: terminalManager.ghosttyRuntime)) + .background(WindowTintBackdrop(runtime: surfaceManager.ghosttyRuntime)) + .background(WindowChromeObserver(runtime: surfaceManager.ghosttyRuntime)) .background( WindowTitleHost( repositoriesStore: repositoriesStore, - terminalManager: terminalManager + surfaceManager: surfaceManager ) ) } @@ -185,7 +185,7 @@ private struct CommandPaletteOverlayHost: View { /// invalidations from tab renames or section title edits. private struct WindowTitleHost: View { let repositoriesStore: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager var body: some View { #if DEBUG @@ -195,7 +195,7 @@ private struct WindowTitleHost: View { .navigationTitle( WindowTitle.compute( repositories: repositoriesStore.state, - terminalManager: terminalManager + surfaceManager: surfaceManager ) ) } diff --git a/supacode/App/WindowTitle.swift b/supacode/App/WindowTitle.swift index 665f56c32..079cf171b 100644 --- a/supacode/App/WindowTitle.swift +++ b/supacode/App/WindowTitle.swift @@ -19,7 +19,7 @@ enum WindowTitle { @MainActor static func compute( repositories: RepositoriesFeature.State, - terminalManager: WorktreeTerminalManager + surfaceManager: WorktreeSurfaceManager ) -> String { switch repositories.selection { case .archivedWorktrees: @@ -28,7 +28,7 @@ enum WindowTitle { return worktreeTitle( worktreeID: worktreeID, repositories: repositories, - terminalManager: terminalManager + surfaceManager: surfaceManager ) case .failedRepository(let repositoryID): // A failed remote keeps a placeholder repository whose `name` is the @@ -53,7 +53,7 @@ enum WindowTitle { private static func worktreeTitle( worktreeID: Worktree.ID, repositories: RepositoriesFeature.State, - terminalManager: WorktreeTerminalManager + surfaceManager: WorktreeSurfaceManager ) -> String { guard let repositoryID = repositories.repositoryID(containing: worktreeID), let repository = repositories.repositories[id: repositoryID] @@ -65,7 +65,7 @@ enum WindowTitle { fallback: repository.name, repositories: repositories ) - let tabTitle = terminalManager.stateIfExists(for: worktreeID).flatMap { state in + let tabTitle = surfaceManager.stateIfExists(for: worktreeID).flatMap { state in tabDisplayTitle(in: state) } return format(repo: repoTitle, tab: tabTitle) @@ -84,7 +84,7 @@ enum WindowTitle { } @MainActor - private static func tabDisplayTitle(in state: WorktreeTerminalState) -> String? { + private static func tabDisplayTitle(in state: WorktreeSurfaceState) -> String? { guard let id = state.tabManager.selectedTabId, let tab = state.tabManager.tabs.first(where: { $0.id == id }) else { return nil } diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index d2fae83d3..f01638dc2 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -49,7 +49,7 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { } } } - var terminalManager: WorktreeTerminalManager? + var surfaceManager: WorktreeSurfaceManager? private var bufferedDeeplinkURLs: [URL] = [] func applicationWillTerminate(_ notification: Notification) { @@ -59,10 +59,10 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { // embeds agent records so badges survive relaunch (agents only emit // session_start once per process lifetime), and a second concurrent instance // overwriting the file is an accepted dev-only last-writer-wins window. - terminalManager?.cancelPendingLayoutSaves() + surfaceManager?.cancelPendingLayoutSaves() let agentsBySurface = appStore?.state.agentPresence.agentsBySurface() ?? [:] - terminalManager?.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface) - terminalManager?.rememberSelectedWorktreeZoomOnQuit() + surfaceManager?.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface) + surfaceManager?.rememberSelectedWorktreeZoomOnQuit() } func applicationDidFinishLaunching(_ notification: Notification) { @@ -123,7 +123,7 @@ struct SupacodeApp: App { @NSApplicationDelegateAdaptor(SupacodeAppDelegate.self) private var appDelegate @State private var ghostty: GhosttyRuntime @State private var ghosttyShortcuts: GhosttyShortcutManager - @State private var terminalManager: WorktreeTerminalManager + @State private var surfaceManager: WorktreeSurfaceManager @State private var worktreeInfoWatcher: WorktreeInfoWatcherManager @State private var commandKeyObserver: CommandKeyObserver @State private var store: StoreOf @@ -177,35 +177,35 @@ struct SupacodeApp: App { _ghostty = State(initialValue: runtime) let shortcuts = GhosttyShortcutManager(runtime: runtime) _ghosttyShortcuts = State(initialValue: shortcuts) - let terminalManager = Self.makeTerminalManager(runtime: runtime) - _terminalManager = State(initialValue: terminalManager) + let surfaceManager = Self.makeTerminalManager(runtime: runtime) + _surfaceManager = State(initialValue: surfaceManager) let worktreeInfoWatcher = WorktreeInfoWatcherManager() _worktreeInfoWatcher = State(initialValue: worktreeInfoWatcher) let keyObserver = CommandKeyObserver() _commandKeyObserver = State(initialValue: keyObserver) let appStore = Self.makeStore( initialSettings: initialSettings, - terminalManager: terminalManager, + surfaceManager: surfaceManager, worktreeInfoWatcher: worktreeInfoWatcher ) _store = State(initialValue: appStore) appDelegate.appStore = appStore - appDelegate.terminalManager = terminalManager + appDelegate.surfaceManager = surfaceManager // Source live agent badge records for incremental layout captures; the [:] // default would clobber badges that share a surface key on every save. - terminalManager.currentAgentsBySurface = { [weak appStore] in + surfaceManager.currentAgentsBySurface = { [weak appStore] in appStore?.state.agentPresence.agentsBySurface() ?? [:] } - Self.configureSocketHandlers(terminalManager: terminalManager, store: appStore) + Self.configureSocketHandlers(surfaceManager: surfaceManager, store: appStore) } @MainActor - private static func makeTerminalManager(runtime: GhosttyRuntime) -> WorktreeTerminalManager { - let terminalManager = WorktreeTerminalManager(runtime: runtime) - runtime.focusedSurfaceBackgroundColorProvider = { [weak terminalManager] in - terminalManager?.focusedSurfaceBackground + private static func makeTerminalManager(runtime: GhosttyRuntime) -> WorktreeSurfaceManager { + let surfaceManager = WorktreeSurfaceManager(runtime: runtime) + runtime.focusedSurfaceBackgroundColorProvider = { [weak surfaceManager] in + surfaceManager?.focusedSurfaceBackground } - terminalManager.saveLayoutSnapshot = { worktreeID, snapshot in + surfaceManager.saveLayoutSnapshot = { worktreeID, snapshot in @Shared(.layouts) var layouts: [String: TerminalLayoutSnapshot] = [:] $layouts.withLock { dict in if let snapshot { @@ -215,68 +215,68 @@ struct SupacodeApp: App { } } } - terminalManager.loadLayoutSnapshot = { worktreeID in + surfaceManager.loadLayoutSnapshot = { worktreeID in @SharedReader(.layouts) var layouts: [String: TerminalLayoutSnapshot] = [:] return layouts[worktreeID.rawValue] } - return terminalManager + return surfaceManager } @MainActor private static func makeStore( initialSettings: GlobalSettings, - terminalManager: WorktreeTerminalManager, + surfaceManager: WorktreeSurfaceManager, worktreeInfoWatcher: WorktreeInfoWatcherManager ) -> StoreOf { Store(initialState: AppFeature.State(settings: SettingsFeature.State(settings: initialSettings))) { AppFeature() .logActions() } withDependencies: { values in - values.terminalClient = TerminalClient( + values.surfaceClient = SurfaceClient( send: { command in - terminalManager.handleCommand(command) + surfaceManager.handleCommand(command) }, events: { - terminalManager.eventStream() + surfaceManager.eventStream() }, tabExists: { worktreeID, tabID in - terminalManager.tabExists(worktreeID: worktreeID, tabID: tabID) + surfaceManager.tabExists(worktreeID: worktreeID, tabID: tabID) }, surfaceExists: { worktreeID, tabID, surfaceID in - terminalManager.surfaceExists(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID) + surfaceManager.surfaceExists(worktreeID: worktreeID, tabID: tabID, surfaceID: surfaceID) }, surfaceExistsInWorktree: { worktreeID, surfaceID in - terminalManager.surfaceExistsInWorktree(worktreeID: worktreeID, surfaceID: surfaceID) + surfaceManager.surfaceExistsInWorktree(worktreeID: worktreeID, surfaceID: surfaceID) }, tabID: { worktreeID, surfaceID in - terminalManager.tabID(forWorktreeID: worktreeID, surfaceID: surfaceID) + surfaceManager.tabID(forWorktreeID: worktreeID, surfaceID: surfaceID) }, selectedTabID: { worktreeID in - terminalManager.stateIfExists(for: worktreeID)?.tabManager.selectedTabId + surfaceManager.stateIfExists(for: worktreeID)?.tabManager.selectedTabId }, selectedSurfaceID: { worktreeID in - guard let state = terminalManager.stateIfExists(for: worktreeID), + guard let state = surfaceManager.stateIfExists(for: worktreeID), let tabID = state.tabManager.selectedTabId else { return nil } return state.activeSurfaceID(for: tabID) }, latestUnreadNotification: { - terminalManager.latestUnreadNotificationLocation() + surfaceManager.latestUnreadNotificationLocation() }, markNotificationRead: { worktreeID, notificationID in - terminalManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) + surfaceManager.markNotificationRead(worktreeID: worktreeID, notificationID: notificationID) }, hasInflightBlockingScripts: { - terminalManager.hasInflightBlockingScripts + surfaceManager.hasInflightBlockingScripts }, terminateAllSessions: { - await terminalManager.terminateAllSessions() + await surfaceManager.terminateAllSessions() }, reapOrphanSessions: { knownSurfaceIDs in - await terminalManager.reapOrphanSessions(knownSurfaceIDs: knownSurfaceIDs) + await surfaceManager.reapOrphanSessions(knownSurfaceIDs: knownSurfaceIDs) }, saveLayoutsWithAgents: { agentsBySurface in - terminalManager.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface) + surfaceManager.saveAllLayoutSnapshots(agentsBySurface: agentsBySurface) } ) values.worktreeInfoWatcher = WorktreeInfoWatcherClient( @@ -310,18 +310,18 @@ struct SupacodeApp: App { @MainActor private static func configureSocketHandlers( - terminalManager: WorktreeTerminalManager, + surfaceManager: WorktreeSurfaceManager, store: StoreOf ) { - terminalManager.onDeeplinkCommand = { url, clientFD in + surfaceManager.onDeeplinkCommand = { url, clientFD in store.send(.deeplinkReceived(url, source: .socket, responseFD: clientFD)) } - terminalManager.onQuery = { resource, params, clientFD in + surfaceManager.onQuery = { resource, params, clientFD in Self.handleQuery( resource: resource, params: params, clientFD: clientFD, - terminalManager: terminalManager, + surfaceManager: surfaceManager, store: store ) } @@ -337,7 +337,7 @@ struct SupacodeApp: App { resource: String, params: [String: String], clientFD: Int32, - terminalManager: WorktreeTerminalManager, + surfaceManager: WorktreeSurfaceManager, store: StoreOf ) { let repos = store.repositories.repositories @@ -367,7 +367,7 @@ struct SupacodeApp: App { clientFD: clientFD, ok: false, error: "Missing worktreeID for tab list.") return } - let tabs = terminalManager.listTabs(worktreeID: worktreeID) + let tabs = surfaceManager.listTabs(worktreeID: worktreeID) if tabs == nil { let decoded = worktreeID.removingPercentEncoding ?? worktreeID let worktreeExists = repos.contains { $0.worktrees.contains { $0.id.rawValue == decoded } } @@ -384,7 +384,7 @@ struct SupacodeApp: App { clientFD: clientFD, ok: false, error: "Missing worktreeID/tabID for surface list.") return } - guard let surfaces = terminalManager.listSurfaces(worktreeID: worktreeID, tabID: tabID) else { + guard let surfaces = surfaceManager.listSurfaces(worktreeID: worktreeID, tabID: tabID) else { AgentHookSocketServer.sendCommandResponse( clientFD: clientFD, ok: false, error: "Worktree or tab not found.") return @@ -486,7 +486,7 @@ struct SupacodeApp: App { var body: some Scene { Window("Supacode", id: WindowID.main) { GhosttyColorSchemeSyncView(ghostty: ghostty) { - ContentView(store: store, terminalManager: terminalManager) + ContentView(store: store, surfaceManager: surfaceManager) .environment(ghosttyShortcuts) .environment(commandKeyObserver) } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/SurfaceClient.swift similarity index 68% rename from supacode/Clients/Terminal/TerminalClient.swift rename to supacode/Clients/Terminal/SurfaceClient.swift index 7c52ac725..d9bafb28e 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/SurfaceClient.swift @@ -2,7 +2,7 @@ import ComposableArchitecture import Foundation import SupacodeSettingsShared -struct TerminalClient { +struct SurfaceClient { var send: @MainActor @Sendable (Command) -> Void var events: @MainActor @Sendable () -> AsyncStream var tabExists: @MainActor @Sendable (Worktree.ID, TerminalTabID) -> Bool @@ -73,7 +73,7 @@ struct TerminalClient { /// A tab was destroyed in the worktree state. Parent removes the matching /// `TerminalTabFeature.State` from `terminalTabs`. case tabRemoved(worktreeID: Worktree.ID, tabID: TerminalTabID) - /// The entire `WorktreeTerminalState` was torn down (worktree pruned). + /// The entire `WorktreeSurfaceState` was torn down (worktree pruned). /// Parent drops any orphan `terminalTabs` entries and removed-tab FIFO /// records owned by this worktree so a fresh re-attach starts clean. case worktreeStateTornDown(worktreeID: Worktree.ID) @@ -95,45 +95,45 @@ struct TerminalClient { } } -extension TerminalClient: DependencyKey { - static let liveValue = TerminalClient( - send: { _ in fatalError("TerminalClient.send not configured") }, - events: { fatalError("TerminalClient.events not configured") }, - tabExists: { _, _ in fatalError("TerminalClient.tabExists not configured") }, - surfaceExists: { _, _, _ in fatalError("TerminalClient.surfaceExists not configured") }, - surfaceExistsInWorktree: { _, _ in fatalError("TerminalClient.surfaceExistsInWorktree not configured") }, - tabID: { _, _ in fatalError("TerminalClient.tabID not configured") }, - selectedTabID: { _ in fatalError("TerminalClient.selectedTabID not configured") }, - selectedSurfaceID: { _ in fatalError("TerminalClient.selectedSurfaceID not configured") }, - latestUnreadNotification: { fatalError("TerminalClient.latestUnreadNotification not configured") }, - markNotificationRead: { _, _ in fatalError("TerminalClient.markNotificationRead not configured") }, - hasInflightBlockingScripts: { fatalError("TerminalClient.hasInflightBlockingScripts not configured") }, - terminateAllSessions: { fatalError("TerminalClient.terminateAllSessions not configured") }, - reapOrphanSessions: { _ in fatalError("TerminalClient.reapOrphanSessions not configured") }, - saveLayoutsWithAgents: { _ in fatalError("TerminalClient.saveLayoutsWithAgents not configured") } +extension SurfaceClient: DependencyKey { + static let liveValue = SurfaceClient( + send: { _ in fatalError("SurfaceClient.send not configured") }, + events: { fatalError("SurfaceClient.events not configured") }, + tabExists: { _, _ in fatalError("SurfaceClient.tabExists not configured") }, + surfaceExists: { _, _, _ in fatalError("SurfaceClient.surfaceExists not configured") }, + surfaceExistsInWorktree: { _, _ in fatalError("SurfaceClient.surfaceExistsInWorktree not configured") }, + tabID: { _, _ in fatalError("SurfaceClient.tabID not configured") }, + selectedTabID: { _ in fatalError("SurfaceClient.selectedTabID not configured") }, + selectedSurfaceID: { _ in fatalError("SurfaceClient.selectedSurfaceID not configured") }, + latestUnreadNotification: { fatalError("SurfaceClient.latestUnreadNotification not configured") }, + markNotificationRead: { _, _ in fatalError("SurfaceClient.markNotificationRead not configured") }, + hasInflightBlockingScripts: { fatalError("SurfaceClient.hasInflightBlockingScripts not configured") }, + terminateAllSessions: { fatalError("SurfaceClient.terminateAllSessions not configured") }, + reapOrphanSessions: { _ in fatalError("SurfaceClient.reapOrphanSessions not configured") }, + saveLayoutsWithAgents: { _ in fatalError("SurfaceClient.saveLayoutsWithAgents not configured") } ) - static let testValue = TerminalClient( + static let testValue = SurfaceClient( send: { _ in }, events: { AsyncStream { $0.finish() } }, - tabExists: unimplemented("TerminalClient.tabExists", placeholder: true), - surfaceExists: unimplemented("TerminalClient.surfaceExists", placeholder: true), - surfaceExistsInWorktree: unimplemented("TerminalClient.surfaceExistsInWorktree", placeholder: true), - tabID: unimplemented("TerminalClient.tabID", placeholder: nil), - selectedTabID: unimplemented("TerminalClient.selectedTabID", placeholder: nil), - selectedSurfaceID: unimplemented("TerminalClient.selectedSurfaceID", placeholder: nil), - latestUnreadNotification: unimplemented("TerminalClient.latestUnreadNotification", placeholder: nil), - markNotificationRead: unimplemented("TerminalClient.markNotificationRead"), - hasInflightBlockingScripts: unimplemented("TerminalClient.hasInflightBlockingScripts", placeholder: false), - terminateAllSessions: unimplemented("TerminalClient.terminateAllSessions"), - reapOrphanSessions: unimplemented("TerminalClient.reapOrphanSessions"), - saveLayoutsWithAgents: unimplemented("TerminalClient.saveLayoutsWithAgents") + tabExists: unimplemented("SurfaceClient.tabExists", placeholder: true), + surfaceExists: unimplemented("SurfaceClient.surfaceExists", placeholder: true), + surfaceExistsInWorktree: unimplemented("SurfaceClient.surfaceExistsInWorktree", placeholder: true), + tabID: unimplemented("SurfaceClient.tabID", placeholder: nil), + selectedTabID: unimplemented("SurfaceClient.selectedTabID", placeholder: nil), + selectedSurfaceID: unimplemented("SurfaceClient.selectedSurfaceID", placeholder: nil), + latestUnreadNotification: unimplemented("SurfaceClient.latestUnreadNotification", placeholder: nil), + markNotificationRead: unimplemented("SurfaceClient.markNotificationRead"), + hasInflightBlockingScripts: unimplemented("SurfaceClient.hasInflightBlockingScripts", placeholder: false), + terminateAllSessions: unimplemented("SurfaceClient.terminateAllSessions"), + reapOrphanSessions: unimplemented("SurfaceClient.reapOrphanSessions"), + saveLayoutsWithAgents: unimplemented("SurfaceClient.saveLayoutsWithAgents") ) } extension DependencyValues { - var terminalClient: TerminalClient { - get { self[TerminalClient.self] } - set { self[TerminalClient.self] = newValue } + var surfaceClient: SurfaceClient { + get { self[SurfaceClient.self] } + set { self[SurfaceClient.self] = newValue } } } diff --git a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index f243163f8..1c2b50a50 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -97,7 +97,7 @@ extension AppFeature.Action { case .settings: return true // Only `notificationIndicatorChanged` writes the snapshot's count field. - case .terminalEvent(let event): + case .surfaceEvent(let event): switch event { case .notificationIndicatorChanged: return true @@ -110,7 +110,7 @@ extension AppFeature.Action { // Hot agent-storm paths: per-tab churn never mutates snapshot inputs. // `.terminals` is safe because it owns only per-tab feature state; any // change that DOES affect a snapshot input flows back through a separate - // `.terminalEvent.notificationIndicatorChanged` (counted above) or a + // `.surfaceEvent.notificationIndicatorChanged` (counted above) or a // `.repositories` cache invalidation (the cacheInvalidations gate above). case .agentPresence, .terminals, .commandPalette, .updates: return false diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 2d4dd3d67..5df39ad7d 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -229,7 +229,7 @@ struct AppFeature { case deeplinkReferenceOpened case alert(PresentationAction) case deeplinkInputConfirmation(PresentationAction) - case terminalEvent(TerminalClient.Event) + case surfaceEvent(SurfaceClient.Event) } enum Alert: Equatable { @@ -246,7 +246,7 @@ struct AppFeature { @Dependency(WorkspaceClient.self) private var workspaceClient @Dependency(NotificationSoundClient.self) private var notificationSoundClient @Dependency(SystemNotificationClient.self) private var systemNotificationClient - @Dependency(TerminalClient.self) private var terminalClient + @Dependency(SurfaceClient.self) private var surfaceClient @Dependency(WorktreeInfoWatcherClient.self) private var worktreeInfoWatcher @Dependency(\.date.now) private var now @Dependency(\.continuousClock) private var clock @@ -272,8 +272,8 @@ struct AppFeature { } }, .run { send in - for await event in await terminalClient.events() { - await send(.terminalEvent(event)) + for await event in await surfaceClient.events() { + await send(.surfaceEvent(event)) } }, .run { send in @@ -288,7 +288,7 @@ struct AppFeature { @SharedReader(.layouts) var layouts: [String: TerminalLayoutSnapshot] = [:] let known = Set(layouts.values.flatMap { $0.allSurfaceIDs }) let staged = AgentPresenceFeature.stageRestore(fromLayouts: layouts.values) - await terminalClient.reapOrphanSessions(known) + await surfaceClient.reapOrphanSessions(known) await send(.agentPresence(.restoreFromSnapshot(staged: staged))) } ) @@ -296,7 +296,7 @@ struct AppFeature { case .agentPresence(.delegate(.surfacesChanged(let surfaces))): // Persist on every presence delta, debounced, so a crash mid-session // doesn't lose the most recent agent state. The save only touches - // worktrees with a live `WorktreeTerminalState`, so it can't write + // worktrees with a live `WorktreeSurfaceState`, so it can't write // rows the user hasn't selected yet. let agentsBySurface = state.agentPresence.agentsBySurface() return .merge( @@ -305,7 +305,7 @@ struct AppFeature { .run { [clock] _ in try await clock.sleep(for: .seconds(1)) await MainActor.run { - terminalClient.saveLayoutsWithAgents(agentsBySurface) + surfaceClient.saveLayoutsWithAgents(agentsBySurface) } } .cancellable(id: CancelID.agentPresencePersist, cancelInFlight: true) @@ -342,7 +342,7 @@ struct AppFeature { .run { [clock] _ in try await clock.sleep(for: .seconds(1)) await MainActor.run { - terminalClient.saveLayoutsWithAgents(agentsBySurface) + surfaceClient.saveLayoutsWithAgents(agentsBySurface) } } .cancellable(id: CancelID.backgroundPersist, cancelInFlight: true) @@ -368,7 +368,7 @@ struct AppFeature { } return .merge( .run { _ in - await terminalClient.send(.setSelectedWorktreeID(nil)) + await surfaceClient.send(.setSelectedWorktreeID(nil)) }, .run { _ in await worktreeInfoWatcher.send(.setSelectedWorktreeID(nil)) @@ -384,7 +384,7 @@ struct AppFeature { let settings = repositorySettings return .merge( .run { _ in - await terminalClient.send(.setSelectedWorktreeID(worktree.id)) + await surfaceClient.send(.setSelectedWorktreeID(worktree.id)) }, .run { _ in await worktreeInfoWatcher.send(.setSelectedWorktreeID(worktree.id)) @@ -396,7 +396,7 @@ struct AppFeature { let shouldRunSetupScript = state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in - await terminalClient.send( + await surfaceClient.send( .ensureInitialTab( worktree, runSetupScriptIfNew: shouldRunSetupScript, @@ -453,7 +453,7 @@ struct AppFeature { .union(state.repositories.environmentBlockedRepositoryIDs) effects.append( .run { [allowed, protectedRepositoryIDs] _ in - await terminalClient.send( + await surfaceClient.send( .prune(keeping: allowed, protectingRepositoryIDs: protectedRepositoryIDs) ) } @@ -508,13 +508,13 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .runBlockingScript(kind: kind, script: script))) + await surfaceClient.send(.terminal(worktree, .runBlockingScript(kind: kind, script: script))) } case .repositories(.delegate(.selectTerminalTab(let worktreeID, let tabId))): guard let worktree = state.repositories.worktree(for: worktreeID) else { return .none } return .run { _ in - await terminalClient.send(.selectTab(worktree, tabID: tabId)) + await surfaceClient.send(.selectTab(worktree, tabID: tabId)) } case .settings(.delegate(.settingsChanged(let settings))): @@ -552,10 +552,10 @@ struct AppFeature { ) ), .run { _ in - await terminalClient.send(.setNotificationsEnabled(settings.inAppNotificationsEnabled)) + await surfaceClient.send(.setNotificationsEnabled(settings.inAppNotificationsEnabled)) }, .run { _ in - await terminalClient.send(.refreshTabBarVisibility) + await surfaceClient.send(.refreshTabBarVisibility) }, .run { _ in await worktreeInfoWatcher.send( @@ -647,7 +647,7 @@ struct AppFeature { } state.alert = quitConfirmationAlert( terminateOnQuit: state.settings.terminateSessionsOnQuit, - hasBlockingScripts: terminalClient.hasInflightBlockingScripts() + hasBlockingScripts: surfaceClient.hasInflightBlockingScripts() ) // Without surfacing the main window, an alert raised from Cmd+Q // when no window is up has no scene to anchor to and `terminate()` @@ -682,7 +682,7 @@ struct AppFeature { state.alert = nil analyticsClient.capture("terminal_sessions_terminated_via_menu", nil) return .run { _ in - await terminalClient.terminateAllSessions() + await surfaceClient.terminateAllSessions() } case .newTerminal: @@ -695,7 +695,7 @@ struct AppFeature { let shouldRunSetupScript = state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in - await terminalClient.send(.createTab(worktree, spec: .terminal(runSetupScriptIfNew: shouldRunSetupScript))) + await surfaceClient.send(.createTab(worktree, spec: .terminal(runSetupScriptIfNew: shouldRunSetupScript))) } case .selectTerminalTabAtIndex(let tabNumber): @@ -708,7 +708,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.selectTabAtIndex(worktree, index: tabNumber)) + await surfaceClient.send(.selectTabAtIndex(worktree, index: tabNumber)) } case .splitTerminal(let direction): @@ -718,11 +718,11 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .performBindingAction(direction.ghosttyBinding))) + await surfaceClient.send(.terminal(worktree, .performBindingAction(direction.ghosttyBinding))) } case .jumpToLatestUnread: - guard let location = terminalClient.latestUnreadNotification() else { + guard let location = surfaceClient.latestUnreadNotification() else { jumpLogger.debug("jumpToLatestUnread invoked with no unread notifications.") return .none } @@ -739,10 +739,10 @@ struct AppFeature { return .merge( .send(.repositories(.selectWorktree(location.worktreeID, focusTerminal: true))), .run { _ in - await terminalClient.send( + await surfaceClient.send( .focusSurface(worktree, tabID: location.tabID, surfaceID: location.surfaceID) ) - await terminalClient.markNotificationRead(location.worktreeID, location.notificationID) + await surfaceClient.markNotificationRead(location.worktreeID, location.notificationID) } ) @@ -792,7 +792,7 @@ struct AppFeature { // The row's `runningScripts` reconciles from the terminal's projection // once the script tab is tracked; no optimistic mirror write (#573). return .run { _ in - await terminalClient.send( + await surfaceClient.send( .terminal(worktree, .runBlockingScript(kind: .script(definition), script: definition.command)) ) } @@ -802,7 +802,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .stopScript(definitionID: definition.id))) + await surfaceClient.send(.terminal(worktree, .stopScript(definitionID: definition.id))) } case .stopRunScripts: @@ -810,7 +810,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .stopRunScript)) + await surfaceClient.send(.terminal(worktree, .stopRunScript)) } case .closeTab: @@ -819,7 +819,7 @@ struct AppFeature { } analyticsClient.capture("terminal_tab_closed", nil) return .run { _ in - await terminalClient.send(.closeFocusedTab(worktree)) + await surfaceClient.send(.closeFocusedTab(worktree)) } case .closeSurface: @@ -827,7 +827,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.closeFocusedSurface(worktree)) + await surfaceClient.send(.closeFocusedSurface(worktree)) } case .startSearch: @@ -835,7 +835,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .startSearch)) + await surfaceClient.send(.terminal(worktree, .startSearch)) } case .searchSelection: @@ -843,7 +843,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .searchSelection)) + await surfaceClient.send(.terminal(worktree, .searchSelection)) } case .navigateSearchNext: @@ -851,7 +851,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .navigateSearchNext)) + await surfaceClient.send(.terminal(worktree, .navigateSearchNext)) } case .navigateSearchPrevious: @@ -859,7 +859,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .navigateSearchPrevious)) + await surfaceClient.send(.terminal(worktree, .navigateSearchPrevious)) } case .endSearch: @@ -867,7 +867,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.terminal(worktree, .endSearch)) + await surfaceClient.send(.terminal(worktree, .endSearch)) } case .settings(.repositorySettings(.delegate(.settingsChanged(let rootURL, let host)))): @@ -1196,19 +1196,19 @@ struct AppFeature { return .none } // Ghostty void actions emit bare tag names; no colon. - let command: TerminalClient.Command + let command: SurfaceClient.Command if action == "prompt_surface_title" || action == "prompt_tab_title" { // Capture the focused tab synchronously so a fast tab switch between dispatch // and effect execution can't redirect the rename to the wrong tab. - let tabID = terminalClient.selectedTabID(worktree.id) + let tabID = surfaceClient.selectedTabID(worktree.id) command = .beginTabRename(worktree, tabID: tabID) - } else if let surfaceID = terminalClient.selectedSurfaceID(worktree.id) { + } else if let surfaceID = surfaceClient.selectedSurfaceID(worktree.id) { command = .terminal(worktree, .performBindingActionOnSurface(surfaceID: surfaceID, action: action)) } else { command = .terminal(worktree, .performBindingAction(action)) } return .run { _ in - await terminalClient.send(command) + await surfaceClient.send(command) } case .commandPalette(.delegate(.openPullRequest(let worktreeID))): @@ -1256,7 +1256,7 @@ struct AppFeature { case .commandPalette: return .none - case .terminalEvent( + case .surfaceEvent( .notificationReceived(let worktreeID, let surfaceID, let title, let body, let isViewed)): var effects: [Effect] = [ .send(.repositories(.worktreeNotificationReceived(worktreeID))) @@ -1280,7 +1280,7 @@ struct AppFeature { } return .merge(effects) - case .terminalEvent(.notificationIndicatorChanged(let count)): + case .surfaceEvent(.notificationIndicatorChanged(let count)): state.notificationIndicatorCount = count return .run { _ in await MainActor.run { @@ -1290,10 +1290,10 @@ struct AppFeature { // Terminal-kind events regroup behind one arm; neutral lifecycle / // projection events keep their own arms below. - case .terminalEvent(.terminal(let event)): + case .surfaceEvent(.terminal(let event)): return handleTerminalSurfaceEvent(event, state: &state) - case .terminalEvent(.commandPaletteToggleRequested(let worktreeID)): + case .surfaceEvent(.commandPaletteToggleRequested(let worktreeID)): if state.commandPalette.isPresented { return .send(.commandPalette(.setPresented(false))) } @@ -1303,7 +1303,7 @@ struct AppFeature { .send(.repositories(.selectWorktree(worktreeID))), .send(.commandPalette(.presentInMode(.commands))) ) - case .terminalEvent(.worktreeProjectionChanged(let worktreeID, var projection)): + case .surfaceEvent(.worktreeProjectionChanged(let worktreeID, var projection)): guard let row = state.repositories.sidebarItems[id: worktreeID] else { return .none } // Archived rows render no running-state dots, so terminal truth must // not re-inject them (see `stripsArchivedRunningScripts`). @@ -1340,7 +1340,7 @@ struct AppFeature { .send(.agentPresence(.delegate(.surfacesChanged(restoredAddedSurfaces)))) ) - case .terminalEvent(.tabProjectionChanged(let worktreeID, let projection)): + case .surfaceEvent(.tabProjectionChanged(let worktreeID, let projection)): // Resolve tab-new / surface-split acks once the supplied id appears. let ackEffect = resolveCommandAcks(ok: true, state: &state) { match in switch match { @@ -1356,7 +1356,7 @@ struct AppFeature { .send(.terminals(.tabProjectionChanged(worktreeID: worktreeID, projection: projection))), ackEffect) - case .terminalEvent(.tabCreated(let worktreeID)): + case .surfaceEvent(.tabCreated(let worktreeID)): // Resolve worktree-new acks once the new worktree's first tab exists, // returning the created worktree id to the CLI. return resolveCommandAcks( @@ -1366,7 +1366,7 @@ struct AppFeature { return false } - case .terminalEvent(.surfaceCreationFailed(let worktreeID, let attemptedID, let message)): + case .surfaceEvent(.surfaceCreationFailed(let worktreeID, let attemptedID, let message)): return resolveCommandAcks(ok: false, error: message, state: &state) { match in switch match { case .tabInWorktree(let ackWorktree, let tabID): @@ -1378,7 +1378,7 @@ struct AppFeature { } } - case .terminalEvent(.tabRemoved(let worktreeID, let tabID)): + case .surfaceEvent(.tabRemoved(let worktreeID, let tabID)): let ackEffect = resolveCommandAcks(ok: true, state: &state) { match in if case .tabRemoved(let ackWorktree, let removed) = match { return ackWorktree == worktreeID && removed == tabID @@ -1388,10 +1388,10 @@ struct AppFeature { return .merge( .send(.terminals(.tabRemoved(worktreeID: worktreeID, tabID: tabID))), ackEffect) - case .terminalEvent(.worktreeStateTornDown(let worktreeID)): + case .surfaceEvent(.worktreeStateTornDown(let worktreeID)): return .send(.terminals(.worktreeStateTornDown(worktreeID: worktreeID))) - case .terminalEvent(.tabProgressDisplayChanged(_, let tabID, let display)): + case .surfaceEvent(.tabProgressDisplayChanged(_, let tabID, let display)): return .send( .terminals(.terminalTabs(.element(id: tabID, action: .progressDisplayChanged(display)))) ) @@ -1399,7 +1399,7 @@ struct AppFeature { case .terminals: return .none - case .terminalEvent(.surfacesClosed(let worktreeID, let ids)): + case .surfaceEvent(.surfacesClosed(let worktreeID, let ids)): guard !ids.isEmpty else { return .none } let ackEffect = resolveCommandAcks(ok: true, state: &state) { match in if case .surfaceClosed(let ackWorktree, let surfaceID) = match { @@ -1413,7 +1413,7 @@ struct AppFeature { : .send(.agentPresence(.surfacesClosed(ids))) return .merge(presenceEffect, ackEffect) - case .terminalEvent: + case .surfaceEvent: return .none } } @@ -1485,14 +1485,14 @@ struct AppFeature { surfaces.map { surfaceID in let agents = state.agentPresence.bySurface[surfaceID] ?? [] return .run { _ in - await terminalClient.send(.setImagePasteAgents(surfaceID: surfaceID, agents: agents)) + await surfaceClient.send(.setImagePasteAgents(surfaceID: surfaceID, agents: agents)) } } ) } /// Terminal-kind event handling, delegated from the single - /// `.terminalEvent(.terminal(_))` reducer arm. A future surface kind adds a + /// `.surfaceEvent(.terminal(_))` reducer arm. A future surface kind adds a /// sibling handler behind its own wrapper arm; the neutral arms never grow /// kind-specific cases. private func handleTerminalSurfaceEvent( @@ -1640,7 +1640,7 @@ struct AppFeature { let shouldRunSetupScript = state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in - await terminalClient.send( + await surfaceClient.send( .createTab( worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: shouldRunSetupScript) @@ -2004,7 +2004,7 @@ struct AppFeature { // Reject explicit IDs that collide with an existing or in-flight tab, so a // duplicate id can't have one creation resolve the other's ack. if let id, - terminalClient.tabExists(worktreeID, TerminalTabID(rawValue: id)) + surfaceClient.tabExists(worktreeID, TerminalTabID(rawValue: id)) || Self.hasPendingCreationAck(id: id, state: state) { state.alert = AlertState { @@ -2075,7 +2075,7 @@ struct AppFeature { // Reject explicit IDs that collide with an existing or in-flight surface, so // a duplicate id can't have one split resolve the other's ack. if let id, - terminalClient.surfaceExistsInWorktree(worktreeID, id) + surfaceClient.surfaceExistsInWorktree(worktreeID, id) || Self.hasPendingCreationAck(id: id, state: state) { state.alert = AlertState { @@ -2170,11 +2170,11 @@ struct AppFeature { ) } analyticsClient.capture("script_run", ["kind": definition.kind.rawValue]) - let terminalClient = terminalClient + let surfaceClient = surfaceClient // The row's `runningScripts` reconciles from the terminal's projection // once the script tab is tracked; no optimistic mirror write (#573). return .run { _ in - await terminalClient.send( + await surfaceClient.send( .terminal(worktree, .runBlockingScript(kind: .script(definition), script: definition.command)) ) } @@ -2205,9 +2205,9 @@ struct AppFeature { ) return .none } - let terminalClient = terminalClient + let surfaceClient = surfaceClient return .run { _ in - await terminalClient.send(.terminal(worktree, .stopScript(definitionID: scriptID))) + await surfaceClient.send(.terminal(worktree, .stopScript(definitionID: scriptID))) } } @@ -2423,15 +2423,15 @@ struct AppFeature { private func sendTerminalCommand( worktreeID: Worktree.ID, state: State, - command: (Worktree) -> TerminalClient.Command + command: (Worktree) -> SurfaceClient.Command ) -> Effect { guard let worktree = state.repositories.worktree(for: worktreeID) else { deeplinkLogger.warning("Worktree \(worktreeID) vanished before terminal command could be dispatched.") return .none } let cmd = command(worktree) - let terminalClient = terminalClient - return .run { _ in await terminalClient.send(cmd) } + let surfaceClient = surfaceClient + return .run { _ in await surfaceClient.send(cmd) } } /// True when in-flight work would not survive a quit. Steady-state @@ -2440,7 +2440,7 @@ struct AppFeature { /// prompt-waiting (`.awaitingInput`) agents are at risk. Running user /// scripts also block because their stdout history dies with the shell. private func hasActiveWorkBlockingQuit(state: State) -> Bool { - if terminalClient.hasInflightBlockingScripts() { return true } + if surfaceClient.hasInflightBlockingScripts() { return true } return state.repositories.sidebarItems.contains { item in if item.lifecycle.isTerminating || item.lifecycle == .pending { return true } if !item.runningScripts.isEmpty { return true } @@ -2519,9 +2519,9 @@ struct AppFeature { analyticsClient.capture("app_quit", ["terminate_sessions": terminateSessions]) let pendingFDEffect = drainPendingResponseFD(state: &state, error: "Supacode is quitting.") let pendingAcksEffect = drainAllCommandAcks(state: &state, error: "Supacode is quitting.") - let terminateEffect: Effect = .run { @MainActor [terminalClient, appLifecycleClient] _ in + let terminateEffect: Effect = .run { @MainActor [surfaceClient, appLifecycleClient] _ in if terminateSessions { - await terminalClient.terminateAllSessions() + await surfaceClient.terminateAllSessions() } appLifecycleClient.terminate() } @@ -2750,7 +2750,7 @@ struct AppFeature { tabID: UUID, state: inout State ) -> Bool { - guard terminalClient.tabExists(worktreeID, TerminalTabID(rawValue: tabID)) else { + guard surfaceClient.tabExists(worktreeID, TerminalTabID(rawValue: tabID)) else { deeplinkLogger.warning("Tab \(tabID) not found in worktree \(worktreeID)") state.alert = AlertState { TextState("Tab not found") @@ -2774,7 +2774,7 @@ struct AppFeature { state: inout State ) -> Bool { guard validateTab(worktreeID: worktreeID, tabID: tabID, state: &state) else { return false } - guard terminalClient.surfaceExists(worktreeID, TerminalTabID(rawValue: tabID), surfaceID) else { + guard surfaceClient.surfaceExists(worktreeID, TerminalTabID(rawValue: tabID), surfaceID) else { deeplinkLogger.warning("Surface \(surfaceID) not found in tab \(tabID) of worktree \(worktreeID)") state.alert = AlertState { TextState("Surface not found") @@ -2829,7 +2829,7 @@ struct AppFeature { let percentEncodingSet = CharacterSet.urlPathAllowed.subtracting(.init(charactersIn: "/")) let encodedWorktreeID = worktreeID.rawValue.addingPercentEncoding(withAllowedCharacters: percentEncodingSet) ?? worktreeID.rawValue - guard let tabID = terminalClient.tabID(worktreeID, surfaceID) else { + guard let tabID = surfaceClient.tabID(worktreeID, surfaceID) else { notificationsLogger.debug( "Surface \(surfaceID) is no longer attached to a tab in \(worktreeID); " + "degrading tap deeplink to the worktree root." diff --git a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift index 649c5c0fb..a51936deb 100644 --- a/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift +++ b/supacode/Features/Repositories/Models/ToolbarNotificationGroup.swift @@ -34,7 +34,7 @@ struct ToolbarNotificationWorktreeGroup: Identifiable, Equatable { extension RepositoriesFeature.State { /// Reads notification data off the per-row `SidebarItemFeature.State` /// (populated via `terminalProjectionChanged`) instead of the live - /// `WorktreeTerminalManager`, so this is a pure reducer-state computation. + /// `WorktreeSurfaceManager`, so this is a pure reducer-state computation. /// Cached on `toolbarNotificationGroupsCache`; views read the cache. func computeToolbarNotificationGroups() -> [ToolbarNotificationRepositoryGroup] { let repositoriesByID = Dictionary(uniqueKeysWithValues: repositories.map { ($0.id, $0) }) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift index ba96320de..78796e148 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+Sidebar.swift @@ -17,7 +17,7 @@ extension RepositoriesFeature { let previousByID = state.sidebarItems var rebuilt: IdentifiedArrayOf = [] // Seed `surfaceIDs` from persisted layout so the surface-to-row index is - // populated before the lazy `WorktreeTerminalState` ever exists. + // populated before the lazy `WorktreeSurfaceState` ever exists. let layouts = state.persistedLayouts var seededSurfaces: Set = [] diff --git a/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift b/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift index 072c8054a..63eba0230 100644 --- a/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift +++ b/supacode/Features/Repositories/Views/SidebarHighlightSectionsView.swift @@ -9,7 +9,7 @@ struct SidebarHighlightSection: View { let kind: SidebarStructure.HighlightKind let rowIDs: [Worktree.ID] let store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let selectedWorktreeIDs: Set let repositoryHighlightByID: [Repository.ID: SidebarHighlightRepoTag] /// Hint string to render in the row's trailing slot, keyed by `Worktree.ID`. @@ -23,7 +23,7 @@ struct SidebarHighlightSection: View { SidebarHighlightRow( rowID: rowID, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, repositoryHighlightByID: repositoryHighlightByID, shortcutHint: shortcutHintByID[rowID] @@ -69,7 +69,7 @@ struct SidebarHighlightHeaderDot: View { private struct SidebarHighlightRow: View { let rowID: SidebarItemID @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let selectedWorktreeIDs: Set let repositoryHighlightByID: [Repository.ID: SidebarHighlightRepoTag] let shortcutHint: String? @@ -81,7 +81,7 @@ private struct SidebarHighlightRow: View { SidebarItemRow( rowID: rowID, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: false, hideSubtitle: false, diff --git a/supacode/Features/Repositories/Views/SidebarItemsView.swift b/supacode/Features/Repositories/Views/SidebarItemsView.swift index 790876267..4c5c6e00b 100644 --- a/supacode/Features/Repositories/Views/SidebarItemsView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemsView.swift @@ -18,7 +18,7 @@ struct SidebarItemsView: View { let shortcutHintByID: [Worktree.ID: String] let selectedWorktreeIDs: Set @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager @Shared(.sidebarNestWorktreesByBranch) private var nestWorktreesByBranch: Bool var body: some View { @@ -28,7 +28,7 @@ struct SidebarItemsView: View { groups: groups, selectedWorktreeIDs: selectedWorktreeIDs, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, isRepositoryRemoving: isRepositoryRemoving, shortcutHintByID: shortcutHintByID, nestWorktreesByBranch: nestWorktreesByBranch && repository.isGitRepository @@ -43,7 +43,7 @@ private struct SidebarItemsDragOverlay: View { let groups: [SidebarItemGroup] let selectedWorktreeIDs: Set @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let isRepositoryRemoving: Bool let shortcutHintByID: [Worktree.ID: String] let nestWorktreesByBranch: Bool @@ -55,7 +55,7 @@ private struct SidebarItemsDragOverlay: View { rowIDs: group.rowIDs, selectedWorktreeIDs: selectedWorktreeIDs, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: group.hideSubtitle, moveBehavior: group.moveBehavior, @@ -71,7 +71,7 @@ private struct SidebarItemGroupView: View { let rowIDs: [SidebarItemID] let selectedWorktreeIDs: Set @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let isRepositoryRemoving: Bool let hideSubtitle: Bool let moveBehavior: SidebarItemGroup.MoveBehavior @@ -108,7 +108,7 @@ private struct SidebarItemGroupView: View { bucketID: moveBehavior.bucketID, row: row, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: hideSubtitle, @@ -124,7 +124,7 @@ private struct SidebarItemGroupView: View { bucketID: moveBehavior.bucketID, row: row, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: hideSubtitle, @@ -139,7 +139,7 @@ private struct SidebarItemGroupView: View { bucketID: moveBehavior.bucketID, row: row, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: hideSubtitle, @@ -217,7 +217,7 @@ private struct SidebarBranchNestingRowView: View { let bucketID: SidebarBucket? let row: SidebarBranchNesting.Row @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let selectedWorktreeIDs: Set let isRepositoryRemoving: Bool let hideSubtitle: Bool @@ -230,7 +230,7 @@ private struct SidebarBranchNestingRowView: View { SidebarItemRow( rowID: id, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: hideSubtitle, @@ -408,7 +408,7 @@ enum SidebarRowMoveMode { struct SidebarItemRow: View { let rowID: SidebarItemID @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let selectedWorktreeIDs: Set let isRepositoryRemoving: Bool let hideSubtitle: Bool @@ -425,7 +425,7 @@ struct SidebarItemRow: View { SidebarItemContainer( store: itemStore, parentStore: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: hideSubtitle, @@ -442,7 +442,7 @@ struct SidebarItemRow: View { private struct SidebarItemContainer: View { let store: StoreOf @Bindable var parentStore: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let selectedWorktreeIDs: Set let isRepositoryRemoving: Bool let hideSubtitle: Bool @@ -457,7 +457,7 @@ private struct SidebarItemContainer: View { SidebarItemBody( store: store, parentStore: parentStore, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: hideSubtitle, @@ -474,7 +474,7 @@ private struct SidebarItemContainer: View { private struct SidebarItemBody: View { let store: StoreOf @Bindable var parentStore: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let selectedWorktreeIDs: Set let isRepositoryRemoving: Bool let hideSubtitle: Bool @@ -506,7 +506,7 @@ private struct SidebarItemBody: View { highlightSubtitle: highlightSubtitle ) .environment(\.focusNotificationAction) { notification in - guard let terminalState = terminalManager.stateIfExists(for: rowID) else { + guard let terminalState = surfaceManager.stateIfExists(for: rowID) else { notificationLogger.warning( "No terminal state for worktree \(rowID) when focusing notification \(notification.surfaceID).") return @@ -560,14 +560,14 @@ struct SidebarFolderRow: View { let shortcutHint: String? let selectedWorktreeIDs: Set @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager var body: some View { let isRepositoryRemoving = store.state.isRemovingRepository(repository) SidebarItemRow( rowID: rowID, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, isRepositoryRemoving: isRepositoryRemoving, hideSubtitle: true, diff --git a/supacode/Features/Repositories/Views/SidebarListView.swift b/supacode/Features/Repositories/Views/SidebarListView.swift index b33ed5916..59b86b7e3 100644 --- a/supacode/Features/Repositories/Views/SidebarListView.swift +++ b/supacode/Features/Repositories/Views/SidebarListView.swift @@ -7,7 +7,7 @@ import SwiftUI struct SidebarListView: View { @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager @FocusState private var isSidebarFocused: Bool @Environment(CommandKeyObserver.self) private var commandKeyObserver @Shared(.settingsFile) private var settingsFile @@ -54,7 +54,7 @@ struct SidebarListView: View { shortcutHintByID: shortcutHintByID, selectedWorktreeIDs: selectedWorktreeIDs, store: store, - terminalManager: terminalManager + surfaceManager: surfaceManager ) } .onMove { offsets, destination in @@ -95,7 +95,7 @@ struct SidebarListView: View { guard let worktreeID = store.selectedWorktreeID, state.sidebarSelectedWorktreeIDs.count == 1, state.sidebarSelectedWorktreeIDs.contains(worktreeID), - let terminalState = terminalManager.stateIfExists(for: worktreeID) + let terminalState = surfaceManager.stateIfExists(for: worktreeID) else { return .ignored } terminalState.focusAndInsertText(keyPress.characters) return .handled @@ -106,7 +106,7 @@ struct SidebarListView: View { guard let worktreeID = store.selectedWorktreeID, state.sidebarSelectedWorktreeIDs.count == 1, state.sidebarSelectedWorktreeIDs.contains(worktreeID), - let terminalState = terminalManager.stateIfExists(for: worktreeID) + let terminalState = surfaceManager.stateIfExists(for: worktreeID) else { return false } terminalState.focusSelectedTab() return true @@ -196,7 +196,7 @@ private struct SidebarSectionDispatcher: View { let shortcutHintByID: [Worktree.ID: String] let selectedWorktreeIDs: Set @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager var body: some View { switch section { @@ -208,7 +208,7 @@ private struct SidebarSectionDispatcher: View { kind: kind, rowIDs: rowIDs, store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, selectedWorktreeIDs: selectedWorktreeIDs, repositoryHighlightByID: structure.repositoryHighlightByID, shortcutHintByID: shortcutHintByID @@ -242,7 +242,7 @@ private struct SidebarSectionDispatcher: View { shortcutHint: shortcutHintByID[rowID], selectedWorktreeIDs: selectedWorktreeIDs, store: store, - terminalManager: terminalManager + surfaceManager: surfaceManager ) } header: { EmptyView() @@ -257,7 +257,7 @@ private struct SidebarSectionDispatcher: View { shortcutHintByID: shortcutHintByID, selectedWorktreeIDs: selectedWorktreeIDs, store: store, - terminalManager: terminalManager + surfaceManager: surfaceManager ) } } @@ -273,7 +273,7 @@ private struct SidebarGitRepositorySection: View { let shortcutHintByID: [Worktree.ID: String] let selectedWorktreeIDs: Set @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager var body: some View { let isRemovingRepository = store.state.isRemovingRepository(repository) let isResolvingRemote = store.state.resolvingRemoteRepositoryIDs.contains(repository.id) @@ -285,7 +285,7 @@ private struct SidebarGitRepositorySection: View { shortcutHintByID: shortcutHintByID, selectedWorktreeIDs: selectedWorktreeIDs, store: store, - terminalManager: terminalManager + surfaceManager: surfaceManager ) if let hoistSummary { SidebarHoistSummaryRow( diff --git a/supacode/Features/Repositories/Views/SidebarView.swift b/supacode/Features/Repositories/Views/SidebarView.swift index c97c34924..2ff28292a 100644 --- a/supacode/Features/Repositories/Views/SidebarView.swift +++ b/supacode/Features/Repositories/Views/SidebarView.swift @@ -5,7 +5,7 @@ import SwiftUI struct SidebarView: View { @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager @Shared(.settingsFile) private var settingsFile var body: some View { @@ -34,7 +34,7 @@ struct SidebarView: View { return SidebarListView( store: store, - terminalManager: terminalManager + surfaceManager: surfaceManager ) .toolbar { ToolbarItem(placement: .primaryAction) { diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index db7d366e1..43a26c7d5 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -12,7 +12,7 @@ import SwiftUI struct WorktreeDetailView: View { @Bindable var store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager @Shared(.appStorage("worktreeRowHideSubtitleOnMatch")) private var hideSubtitleOnMatch = true @Shared(.settingsFile) private var settingsFile: SettingsFile private var agentBadgesEnabled: Bool { settingsFile.global.agentPresenceBadgesEnabled } @@ -69,7 +69,7 @@ struct WorktreeDetailView: View { // Read the manager's stored color here (tracked body evaluation, not the // deferred toolbar closure) so the toolbar scheme invalidates on change. let toolbarScheme: ColorScheme = - terminalManager.focusedSurfaceBackground.isLightColor ? .light : .dark + surfaceManager.focusedSurfaceBackground.isLightColor ? .light : .dark let content = detailContent( repositories: repositories, loadingInfo: loadingInfo, @@ -82,7 +82,7 @@ struct WorktreeDetailView: View { .toolbar { WorktreeDetailToolbar( store: store, - terminalManager: terminalManager, + surfaceManager: surfaceManager, repositoriesStore: repositoriesStore, scheme: toolbarScheme, showsToolbarPlaceholder: showsToolbarPlaceholder, @@ -109,7 +109,7 @@ struct WorktreeDetailView: View { isCheckingPullRequest: isCheckingPullRequest, pullRequest: inspectorPullRequest, repositoriesStore: repositoriesStore, - terminalManager: terminalManager, + surfaceManager: surfaceManager, onSelectNotification: selectToolbarNotification, onPullRequestAction: { action in if let worktreeID = selectedWorktree?.id { @@ -120,7 +120,7 @@ struct WorktreeDetailView: View { .inspectorColumnWidth(min: 280, ideal: 320, max: 480) // Match the inspector's accent to the terminal background; the appearance // is forced inside `WorktreeStatusInspectorContainer`. - .tint(terminalManager.chromeOverlayTint()) + .tint(surfaceManager.chromeOverlayTint()) } // Reveal in Finder is local-only; Open can target a remote worktree when the // resolved editor can express the host. `resolvedSelection` (nil when it @@ -277,7 +277,7 @@ struct WorktreeDetailView: View { let shouldFocusTerminal = repositories.shouldFocusTerminal(for: selectedWorktree.id) WorktreeTerminalTabsView( worktree: selectedWorktree, - manager: terminalManager, + manager: surfaceManager, terminalsStore: store.scope(state: \.terminals, action: \.terminals), shouldRunSetupScript: shouldRunSetupScript, forceAutoFocus: shouldFocusTerminal, @@ -359,7 +359,7 @@ struct WorktreeDetailView: View { _ notification: WorktreeTerminalNotification ) { store.send(.repositories(.selectWorktree(worktreeID))) - if let terminalState = terminalManager.stateIfExists(for: worktreeID) { + if let terminalState = surfaceManager.stateIfExists(for: worktreeID) { _ = terminalState.focusSurface(id: notification.surfaceID) } } @@ -525,7 +525,7 @@ struct WorktreeDetailView: View { fileprivate struct WorktreeDetailToolbar: ToolbarContent { let store: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let repositoriesStore: StoreOf /// Terminal-derived scheme for the `.navigation` item, whose detached host /// (`.sharedBackgroundVisibility(.hidden)`) ignores `window.appearance`. @@ -553,7 +553,7 @@ struct WorktreeDetailView: View { selectedRow: selectedRow ), repositoriesStore: repositoriesStore, - terminalManager: terminalManager, + surfaceManager: surfaceManager, inspectorPane: inspectorPane, inspectorPresented: inspectorPresented, onActivateInspector: { repositoriesStore.send(.toggleInspectorPane($0)) } @@ -582,7 +582,7 @@ struct WorktreeDetailView: View { WorktreeToolbarContent( scheme: scheme, toolbarState: toolbarState, - terminalManager: terminalManager, + surfaceManager: surfaceManager, repositoriesStore: repositoriesStore, inspectorPane: inspectorPane, inspectorPresented: inspectorPresented, @@ -610,7 +610,7 @@ struct WorktreeDetailView: View { /// (`.sharedBackgroundVisibility(.hidden)`) ignores `window.appearance`. let scheme: ColorScheme let toolbarState: WorktreeToolbarState - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let repositoriesStore: StoreOf? let inspectorPane: WorktreeInspectorPane let inspectorPresented: Bool @@ -663,7 +663,7 @@ struct WorktreeDetailView: View { TrailingStatusToolbarContent( pullRequest: toolbarState.pullRequest, repositoriesStore: repositoriesStore, - terminalManager: terminalManager, + surfaceManager: surfaceManager, inspectorPane: inspectorPane, inspectorPresented: inspectorPresented, onActivateInspector: onActivateInspector @@ -735,7 +735,7 @@ struct WorktreeDetailView: View { fileprivate struct TrailingStatusToolbarContent: ToolbarContent { let pullRequest: GithubPullRequest? let repositoriesStore: StoreOf? - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let inspectorPane: WorktreeInspectorPane let inspectorPresented: Bool let onActivateInspector: (WorktreeInspectorPane) -> Void @@ -744,7 +744,7 @@ struct WorktreeDetailView: View { ToolbarItemGroup { // Translucent chrome-tracking highlight (whiteish on a dark terminal); // full-opacity tint reads as a stark solid pill against the glass. - let chromeForeground = terminalManager.chromeOverlayTint() + let chromeForeground = surfaceManager.chromeOverlayTint() let chromeTint = chromeForeground.opacity(0.2) WorktreeGitStatusButton( pullRequest: pullRequest, @@ -1350,7 +1350,7 @@ private struct WorktreeToolbarPreview: View { WorktreeDetailView.WorktreeToolbarContent( scheme: .light, toolbarState: toolbarState, - terminalManager: WorktreeTerminalManager(runtime: GhosttyRuntime()), + surfaceManager: WorktreeSurfaceManager(runtime: GhosttyRuntime()), repositoriesStore: nil, inspectorPane: .git, inspectorPresented: false, diff --git a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift index 02c886e9c..034b030b3 100644 --- a/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift +++ b/supacode/Features/Repositories/Views/WorktreeStatusInspector.swift @@ -10,7 +10,7 @@ struct WorktreeStatusInspectorContainer: View { let isCheckingPullRequest: Bool let pullRequest: GithubPullRequest? let repositoriesStore: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void let onPullRequestAction: (RepositoriesFeature.PullRequestAction) -> Void @@ -27,12 +27,12 @@ struct WorktreeStatusInspectorContainer: View { case .notifications: WorktreeNotificationsInspectorView( repositoriesStore: repositoriesStore, - terminalManager: terminalManager, + surfaceManager: surfaceManager, onSelectNotification: onSelectNotification ) } } - .inspectorForcedAppearance(terminalManager.surfaceBackgroundColorScheme()) + .inspectorForcedAppearance(surfaceManager.surfaceBackgroundColorScheme()) } } @@ -405,7 +405,7 @@ private struct PullRequestMergeQueueRow: View { /// toolbar bell host. struct WorktreeNotificationsInspectorView: View { let repositoriesStore: StoreOf - let terminalManager: WorktreeTerminalManager + let surfaceManager: WorktreeSurfaceManager let onSelectNotification: (Worktree.ID, WorktreeTerminalNotification) -> Void var body: some View { @@ -416,7 +416,7 @@ struct WorktreeNotificationsInspectorView: View { onDismissAll: { for repositoryGroup in groups { for worktreeGroup in repositoryGroup.worktrees { - terminalManager.stateIfExists(for: worktreeGroup.id)? + surfaceManager.stateIfExists(for: worktreeGroup.id)? .dismissAllNotifications() } } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift similarity index 97% rename from supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift rename to supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift index 20e8ea4ec..35de7059f 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift @@ -11,10 +11,10 @@ private let terminalLogger = SupaLogger("Terminal") @MainActor @Observable -final class WorktreeTerminalManager { +final class WorktreeSurfaceManager { private let runtime: GhosttyRuntime private(set) var socketServer: AgentHookSocketServer? - private var states: [Worktree.ID: WorktreeTerminalState] = [:] + private var states: [Worktree.ID: WorktreeSurfaceState] = [:] @ObservationIgnored @Shared(.settingsFile) private var settingsFile: SettingsFile private var notificationsEnabled = true @@ -24,13 +24,13 @@ final class WorktreeTerminalManager { /// Per-worktree dedup of `worktreeProjectionChanged`; identical projections /// (common on hook storms) are dropped before they hit the AsyncStream. private var lastEmittedProjections: [Worktree.ID: WorktreeRowProjection] = [:] - private var eventContinuation: AsyncStream.Continuation? - private var pendingEvents: [TerminalClient.Event] = [] + private var eventContinuation: AsyncStream.Continuation? + private var pendingEvents: [SurfaceClient.Event] = [] /// Latest-wins events deduped by identity: drops a value equal to the /// immediately-previous one per key (a burst of distinct values still passes), /// so per-tab projection / progress / task-status / focus repeats don't flood /// the stream. Cleared on resubscribe and purged on tab / worktree teardown. - private var lastEmittedCoalescable: [CoalesceKey: TerminalClient.Event] = [:] + private var lastEmittedCoalescable: [CoalesceKey: SurfaceClient.Event] = [:] /// Worktrees whose projection was shed under backpressure, awaiting next-tick /// redelivery. Coalesced so a shed storm replays each id at most once per tick. private var pendingShedProjectionReplays: Set = [] @@ -95,7 +95,7 @@ final class WorktreeTerminalManager { /// Non-nil for state events that are safe to coalesce by identity. Lifecycle / /// one-shot events (tab create / close / remove, notifications, script /// completion, command-palette, teardown) return nil and are never dropped. - private static func coalesceKey(for event: TerminalClient.Event) -> CoalesceKey? { + private static func coalesceKey(for event: SurfaceClient.Event) -> CoalesceKey? { switch event { case .worktreeProjectionChanged(let worktreeID, _): .worktreeProjection(worktreeID) case .tabProjectionChanged(_, let projection): .tabProjection(projection.tabID) @@ -111,7 +111,7 @@ final class WorktreeTerminalManager { /// Compact identity for a backpressure-drop log. Strips the payload-heavy /// cases (projections / notification bodies) to their key ids so a drop storm /// can't flood the log; the rest carry small payloads and describe themselves. - private static func label(for event: TerminalClient.Event) -> String { + private static func label(for event: SurfaceClient.Event) -> String { switch event { case .worktreeProjectionChanged(let worktreeID, _): "worktreeProjectionChanged(\(worktreeID))" case .tabProjectionChanged(let worktreeID, let projection): @@ -142,7 +142,7 @@ final class WorktreeTerminalManager { runtime: GhosttyRuntime, socketServer: AgentHookSocketServer? = nil, clock: C = ContinuousClock(), - eventBufferCap: Int = WorktreeTerminalManager.defaultEventBufferCap, + eventBufferCap: Int = WorktreeSurfaceManager.defaultEventBufferCap, ) { self.eventBufferCap = eventBufferCap self.runtime = runtime @@ -262,7 +262,7 @@ final class WorktreeTerminalManager { return state.listSurfaces(tabID: terminalTabID) } - func handleCommand(_ command: TerminalClient.Command) { + func handleCommand(_ command: SurfaceClient.Command) { // Kind-scoped commands dispatch straight to their kind's handler; the // neutral handlers below never see them. if case .terminal(let worktree, let surfaceCommand) = command { @@ -304,7 +304,7 @@ final class WorktreeTerminalManager { } // swiftlint:disable:next cyclomatic_complexity - private func handleTabCommand(_ command: TerminalClient.Command) -> Bool { + private func handleTabCommand(_ command: SurfaceClient.Command) -> Bool { switch command { case .createTab(let worktree, let spec, let id): switch spec { @@ -399,7 +399,7 @@ final class WorktreeTerminalManager { } } - private func handleManagementCommand(_ command: TerminalClient.Command) { + private func handleManagementCommand(_ command: SurfaceClient.Command) { switch command { case .prune(let ids, let protectedRepositoryIDs): prune(keeping: ids, protectingRepositoryIDs: protectedRepositoryIDs) @@ -430,10 +430,10 @@ final class WorktreeTerminalManager { } } - func eventStream() -> AsyncStream { + func eventStream() -> AsyncStream { eventContinuation?.finish() let (stream, continuation) = AsyncStream.makeStream( - of: TerminalClient.Event.self, + of: SurfaceClient.Event.self, bufferingPolicy: .bufferingNewest(eventBufferCap) ) eventContinuation = continuation @@ -481,7 +481,7 @@ final class WorktreeTerminalManager { func state( for worktree: Worktree, runSetupScriptIfNew: () -> Bool = { false } - ) -> WorktreeTerminalState { + ) -> WorktreeSurfaceState { if let existing = states[worktree.id] { if runSetupScriptIfNew() { existing.enableSetupScriptIfNeeded() @@ -498,7 +498,7 @@ final class WorktreeTerminalManager { return existing } let runSetupScript = runSetupScriptIfNew() - let state = WorktreeTerminalState( + let state = WorktreeSurfaceState( runtime: runtime, worktree: worktree, runSetupScript: runSetupScript @@ -632,10 +632,10 @@ final class WorktreeTerminalManager { keeping worktreeIDs: Set, protectingRepositoryIDs protectedRepositoryIDs: Set = [] ) { - let shouldKeep: (Worktree.ID, WorktreeTerminalState) -> Bool = { id, state in + let shouldKeep: (Worktree.ID, WorktreeSurfaceState) -> Bool = { id, state in worktreeIDs.contains(id) || protectedRepositoryIDs.contains(state.repositoryID) } - var removed: [(Worktree.ID, WorktreeTerminalState)] = [] + var removed: [(Worktree.ID, WorktreeSurfaceState)] = [] for (id, state) in states where !shouldKeep(id, state) { removed.append((id, state)) } @@ -673,7 +673,7 @@ final class WorktreeTerminalManager { /// session may exist from an earlier launch, and the kill invocation is a /// silent no-op when nothing exists. private static func remoteSessions( - in states: [WorktreeTerminalState] + in states: [WorktreeSurfaceState] ) -> [(host: RemoteHost, sessionID: String)] { states.flatMap { state -> [(host: RemoteHost, sessionID: String)] in guard let host = state.remoteHost else { return [] } @@ -836,7 +836,7 @@ final class WorktreeTerminalManager { states[worktreeID]?.allSurfaceIDs ?? [] } - func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeTerminalState? { + func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeSurfaceState? { states[worktreeID] } @@ -852,7 +852,7 @@ final class WorktreeTerminalManager { /// hosts. zmx is a long-lived per-user daemon that outlives our app quit, /// so "Quit and Terminate" must explicitly sweep orphan sessions or they /// would survive forever. - func terminateAllSessions(killBudget: Duration = WorktreeTerminalManager.quitKillBudget) async { + func terminateAllSessions(killBudget: Duration = WorktreeSurfaceManager.quitKillBudget) async { let trackedSurfaceIDs = states.values.flatMap(\.allSurfaceIDs) let trackedSessionIDs = Set(trackedSurfaceIDs.map(ZmxSessionID.make(surfaceID:))) // "Quit and Terminate" promises nothing keeps running, so the host-side @@ -1140,7 +1140,7 @@ final class WorktreeTerminalManager { (runtime.unfocusedSplitFill(), runtime.unfocusedSplitOverlayOpacity()) } - private func emit(_ event: TerminalClient.Event) { + private func emit(_ event: SurfaceClient.Event) { guard let eventContinuation else { bufferPendingEvent(event) return @@ -1166,7 +1166,7 @@ final class WorktreeTerminalManager { /// Redeliver a shed projection next tick; shedding cleared its dedupe entry /// without reaching TCA, so the row would otherwise stay stale (#573). - private func scheduleShedProjectionReplay(for shed: TerminalClient.Event) { + private func scheduleShedProjectionReplay(for shed: SurfaceClient.Event) { guard case .worktreeProjectionChanged(let worktreeID, _) = shed else { return } // A replay that itself sheds must not chain another, or a persistently full // buffer would loop and evict live events every tick (#573). @@ -1189,7 +1189,7 @@ final class WorktreeTerminalManager { /// A shed event never reached the consumer, so its dedupe entries must not /// suppress the next identical emit (#573). - private func invalidateDedupe(for shed: TerminalClient.Event) { + private func invalidateDedupe(for shed: SurfaceClient.Event) { guard let key = Self.coalesceKey(for: shed) else { return } lastEmittedCoalescable.removeValue(forKey: key) switch shed { @@ -1209,7 +1209,7 @@ final class WorktreeTerminalManager { /// Buffers an event emitted before a subscriber attaches. Coalescable state /// keeps only its latest value per key; lifecycle events accumulate up to a /// cap, dropping the oldest so the pre-subscription buffer stays bounded. - private func bufferPendingEvent(_ event: TerminalClient.Event) { + private func bufferPendingEvent(_ event: SurfaceClient.Event) { if let key = Self.coalesceKey(for: event) { pendingEvents.removeAll { Self.coalesceKey(for: $0) == key } pendingEvents.append(event) @@ -1233,7 +1233,7 @@ final class WorktreeTerminalManager { /// Coalesce keys a teardown event invalidates. A coalesced value for a removed /// tab / worktree must not linger: a same-id reuse (snapshot restore reuses /// persisted tab UUIDs) would otherwise be wrongly deduped and dropped. - private static func invalidatedCoalesceKeys(by event: TerminalClient.Event) -> [CoalesceKey] { + private static func invalidatedCoalesceKeys(by event: SurfaceClient.Event) -> [CoalesceKey] { switch event { case .tabRemoved(_, let tabID): [.tabProjection(tabID), .tabProgress(tabID)] case .worktreeStateTornDown(let worktreeID): @@ -1277,7 +1277,7 @@ final class WorktreeTerminalManager { /// Runs `stop` on the worktree's existing terminal state, never minting one. /// A miss with a live state means the caller acted on a stale mirror, so force /// a fresh projection emit past the dedupe cache to reconcile it (#573). - private func stopBlockingScripts(in worktree: Worktree, using stop: (WorktreeTerminalState) -> Bool) { + private func stopBlockingScripts(in worktree: Worktree, using stop: (WorktreeSurfaceState) -> Bool) { guard let state = stateIfExists(for: worktree.id) else { terminalLogger.warning("Stop requested for \(worktree.id) with no terminal state") return diff --git a/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift b/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift index 5d6a017b2..b6639c0da 100644 --- a/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift +++ b/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift @@ -17,7 +17,7 @@ private let dragLogger = SupaLogger("SurfaceDrag") /// always fires, including on cancel, which is why this is AppKit-driven and /// not SwiftUI `.onDrag`, which has no end signal). /// - The catcher reports a landed drop through `completeDrop`, which forwards to -/// `onDrop` (wired by `WorktreeTerminalState` to mutate the split tree). +/// `onDrop` (wired by `WorktreeSurfaceState` to mutate the split tree). @MainActor @Observable final class SurfaceDragCoordinator { diff --git a/supacode/Features/Terminal/Models/SurfaceIndicatorState.swift b/supacode/Features/Terminal/Models/SurfaceIndicatorState.swift new file mode 100644 index 000000000..1a7bb42b1 --- /dev/null +++ b/supacode/Features/Terminal/Models/SurfaceIndicatorState.swift @@ -0,0 +1,10 @@ +import Observation + +/// Per-surface observable kept off `GhosttySurfaceState` so the Ghostty bridge +/// remains a pure mirror of `ghostty_action_*` payloads. +@MainActor +@Observable +final class SurfaceIndicatorState { + /// Mirror of `WorktreeSurfaceState.hasUnseenNotification(forSurfaceID:)`. + var hasUnseenNotification: Bool = false +} diff --git a/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift b/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift index ffadaed13..1fbd60402 100644 --- a/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift +++ b/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift @@ -1,6 +1,6 @@ import Foundation -/// Terminal-only commands, carried by `TerminalClient.Command.terminal`. The +/// Terminal-only commands, carried by `SurfaceClient.Command.terminal`. The /// shared command surface stays kind-neutral; everything that only makes sense /// for a terminal (scripts, Ghostty binding actions, scrollback search) lives /// here, and a future surface kind adds a sibling enum + one wrapper case @@ -18,9 +18,9 @@ enum TerminalSurfaceCommand: Equatable { case endSearch } -/// Terminal-only events, carried by `TerminalClient.Event.terminal`. Mirror of +/// Terminal-only events, carried by `SurfaceClient.Event.terminal`. Mirror of /// `TerminalSurfaceCommand` on the event side: neutral lifecycle / projection -/// events stay top-level on `TerminalClient.Event`. +/// events stay top-level on `SurfaceClient.Event`. enum TerminalSurfaceEvent: Equatable { case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) case blockingScriptCompleted( diff --git a/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift b/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift index ad2acc25b..3753a0933 100644 --- a/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift +++ b/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift @@ -12,7 +12,7 @@ private let storeLogger = SupaLogger("Terminal") /// launch/session bookkeeping, blocking scripts, setup-script consumption, /// working-dir/environment injection, and agent-OSC notification dedupe. /// -/// `WorktreeTerminalState`'s generic core (tree / tab / focus / occlusion / +/// `WorktreeSurfaceState`'s generic core (tree / tab / focus / occlusion / /// zoom / persistence scheduling) reaches terminal-only state exclusively /// through this store, so a future surface kind adds a sibling store instead /// of new fields on the shared class. Everything here is keyed by surface or diff --git a/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift b/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift index ab330448f..3e63091dd 100644 --- a/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift +++ b/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift @@ -1,10 +1,2599 @@ +import AppKit +import CoreGraphics +import Dependencies +import Foundation +import GhosttyKit +import IdentifiedCollections import Observation +import Sharing +import SupacodeSettingsShared + +private let blockingScriptLogger = SupaLogger("BlockingScript") +private let layoutLogger = SupaLogger("Layout") +private let terminalStateLogger = SupaLogger("Terminal") + +/// Per-tab projection emitted by `WorktreeSurfaceState` whenever a tab's +/// surfaces, focus, unread count, or progress display drifts. The parent +/// reducer applies this to the matching `TerminalTabFeature.State` so the +/// tab-bar leaf observes a per-tab store instead of worktree-wide state. +struct WorktreeTabProjection: Equatable, Sendable { + let tabID: TerminalTabID + let surfaceIDs: [UUID] + let activeSurfaceID: UUID? + let unseenNotificationCount: Int + let isSplitZoomed: Bool + /// Per-tab repaint epoch, bumped on same-UUID surface replacement so the view rebuilds. + let surfaceGeneration: Int + + init( + tabID: TerminalTabID, + surfaceIDs: [UUID], + activeSurfaceID: UUID?, + unseenNotificationCount: Int, + isSplitZoomed: Bool = false, + surfaceGeneration: Int = 0, + ) { + self.tabID = tabID + self.surfaceIDs = surfaceIDs + self.activeSurfaceID = activeSurfaceID + self.unseenNotificationCount = unseenNotificationCount + self.isSplitZoomed = isSplitZoomed + self.surfaceGeneration = surfaceGeneration + } +} -/// Per-surface observable kept off `GhosttySurfaceState` so the Ghostty bridge -/// remains a pure mirror of `ghostty_action_*` payloads. @MainActor @Observable final class WorktreeSurfaceState { - /// Mirror of `WorktreeTerminalState.hasUnseenNotification(forSurfaceID:)`. - var hasUnseenNotification: Bool = false + struct SurfaceActivity: Equatable { + let isVisible: Bool + let isFocused: Bool + } + + let tabManager: TerminalTabManager + /// Terminal-kind store: Ghostty view map, zmx/launch bookkeeping, blocking + /// scripts, setup-script flag, env injection, agent-OSC dedupe. The private + /// accessors below keep the generic-core method bodies unchanged while the + /// state itself lives per-kind; a future surface kind adds a sibling store. + @ObservationIgnored let terminal: TerminalSurfaceStore + private let runtime: GhosttyRuntime + @ObservationIgnored private let splitPreserveZoomOnNavigation: () -> Bool + private let worktree: Worktree + @ObservationIgnored + @SharedReader private var repositorySettings: RepositorySettings + // Observed: any mutation re-renders `WorktreeTerminalTabsView`. Mutate only + // from user-initiated structural changes; per-surface churn must stay on + // `surfaceStates` / `WorktreeTabProjection` to keep agent storms cold. + private var trees: [TerminalTabID: SplitTree] = [:] + /// Owns pane-rearrange drag state for this worktree's terminal area. The view + /// layer reads `isDragging` to mount drop catchers; `onDrop` (wired in `init`) + /// routes a landed drop back into `performSplitOperation`. + @ObservationIgnored let dragCoordinator = SurfaceDragCoordinator() + @ObservationIgnored private var surfaceGenerationByTab: [TerminalTabID: Int] = [:] + @ObservationIgnored private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] + /// Per-tab projection cache. `WorktreeSurfaceState` recomputes from `trees` + /// / `notifications` / `focusedSurfaceIdByTab`, compares to the cached value, + /// and fires `onTabProjectionChanged` only on diff. The manager forwards the + /// projection upstream so `TerminalTabFeature.State` mirrors it. + @ObservationIgnored private var lastTabProjections: [TerminalTabID: WorktreeTabProjection] = [:] + /// Per-tab progress-display cache. Tracks the focused-surface or worst-of + /// aggregate so `onTabProgressDisplayChanged` only fires on diff. + @ObservationIgnored private var lastTabProgressDisplays: [TerminalTabID: TerminalTabProgressDisplay?] = [:] + private(set) var shouldHideTabBar = false + /// Coalesces the per-mutation running-scripts emit into one next-tick emit so + /// mid-operation states (e.g. the supersede clear-then-record in + /// `runBlockingScript`) never reach TCA. + @ObservationIgnored private var pendingRunningScriptsProjectionEmit = false + /// Sticky after first attempt so a reselect after `closeAllTabs` doesn't auto-recreate. + /// Intentionally never reset; resetting would re-arm the bug. + @ObservationIgnored private(set) var hasAttemptedInitialTab = false + @ObservationIgnored var pendingLayoutSnapshot: TerminalLayoutSnapshot? + private var lastReportedTaskStatus: WorktreeTaskStatus? + private var lastEmittedFocusSurfaceId: UUID? + private var lastWindowIsKey: Bool? + private var lastWindowIsVisible: Bool? + /// Raw notification log. `@ObservationIgnored` so per-tab notification ticks + /// flow through `TerminalTabState.unseenNotificationCount` projections instead + /// of invalidating every leaf in the worktree. + @ObservationIgnored private(set) var notifications: [WorktreeTerminalNotification] = [] + /// Per-surface Supacode observables. `@ObservationIgnored` so dict churn + /// doesn't invalidate every leaf; the per-instance `hasUnseenNotification` is + /// the observed signal. + @ObservationIgnored private(set) var surfaceStates: [UUID: SurfaceIndicatorState] = [:] + var notificationsEnabled = true + @ObservationIgnored @Dependency(\.date.now) private var now + @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient + @ObservationIgnored @Dependency(\.analyticsClient) private var analyticsClient + @ObservationIgnored @Dependency(\.continuousClock) private var clock + /// How long after a custom notification the agent's own OSC 9 is suppressed. + /// Split from `oscHoldWindow` so tuning the suppression side cannot silently + /// change the hold side. + private static let oscSuppressionAfterCustom: TimeInterval = 0.5 + /// How long the agent's own OSC 9 is held before firing, waiting for a custom + /// notification to supersede it. Covers the socket-vs-inline-stream arrival skew. + private static let oscHoldWindow: TimeInterval = 0.5 + /// Monotonic gap between two instants from the same clock. Opens the existentials + /// so the suppression window can compare instants of the type-erased clock. + private static func elapsed( + from start: any InstantProtocol, + to end: any InstantProtocol + ) -> Duration { + func gap(_ start: I, _ end: any InstantProtocol) -> Duration + where I.Duration == Duration { + guard let end = end as? I else { + // Fail OPEN: a type mismatch must not pin the dedupe window true forever. + assertionFailure("clock instant type mismatch") + return .seconds(Self.oscSuppressionAfterCustom + 1) + } + return start.duration(to: end) + } + return gap(start, end) + } + #if DEBUG + var debugCustomNotificationTimestampCount: Int { lastCustomNotificationAt.count } + var debugPendingOSCCount: Int { pendingAgentOSCNotifications.count } + #endif + // MARK: - Terminal-kind state accessors (storage lives in `terminal`). + + var socketPath: String? { + get { terminal.socketPath } + set { terminal.socketPath = newValue } + } + private var surfaces: [UUID: GhosttySurfaceView] { + get { terminal.surfaces } + set { terminal.surfaces = newValue } + } + private var surfaceLaunchMetadata: [UUID: TerminalSurfaceStore.SurfaceLaunchMetadata] { + get { terminal.surfaceLaunchMetadata } + set { terminal.surfaceLaunchMetadata = newValue } + } + private var pendingExplicitSurfaceCloseIDs: Set { + get { terminal.pendingExplicitSurfaceCloseIDs } + set { terminal.pendingExplicitSurfaceCloseIDs = newValue } + } + // Every mutation schedules a coalesced row-projection emit so the TCA + // mirror of running scripts reconciles from this single source of truth + // (#573). All writes flow through this accessor; the store never mutates + // its copy directly. + private var blockingScripts: [TerminalTabID: BlockingScriptKind] { + get { terminal.blockingScripts } + set { + terminal.blockingScripts = newValue + scheduleRunningScriptsProjectionEmit() + } + } + private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] { + get { terminal.lastBlockingScriptTabByKind } + set { terminal.lastBlockingScriptTabByKind = newValue } + } + private var pendingSetupScript: Bool { + get { terminal.pendingSetupScript } + set { terminal.pendingSetupScript = newValue } + } + private var lastCustomNotificationAt: [UUID: any InstantProtocol] { + get { terminal.lastCustomNotificationAt } + set { terminal.lastCustomNotificationAt = newValue } + } + private var pendingAgentOSCNotifications: [UUID: Task] { + get { terminal.pendingAgentOSCNotifications } + set { terminal.pendingAgentOSCNotifications = newValue } + } + + var hasUnseenNotification: Bool { + notifications.contains { !$0.isRead } + } + + func hasUnseenNotification(forSurfaceID surfaceID: UUID) -> Bool { + notifications.contains { !$0.isRead && $0.surfaceID == surfaceID } + } + + func hasUnseenNotification(forTabID tabID: TerminalTabID) -> Bool { + guard let tree = trees[tabID] else { return false } + let surfaceIDs = Set(tree.leaves().map(\.id)) + return notifications.contains { !$0.isRead && surfaceIDs.contains($0.surfaceID) } + } + + /// Returns the most recent unread notification in this worktree, or nil. + func latestUnreadNotification() -> WorktreeTerminalNotification? { + unreadNotifications().first + } + + /// Returns all unread notifications in this worktree sorted newest first. + func unreadNotifications() -> [WorktreeTerminalNotification] { + notifications.filter { !$0.isRead }.sorted { $0.createdAt > $1.createdAt } + } + + var isSelected: () -> Bool = { false } + var onNotificationReceived: ((UUID, String, String, Bool) -> Void)? + var onNotificationIndicatorChanged: (() -> Void)? + var onTabCreated: (() -> Void)? + var onTabClosed: (() -> Void)? + /// Fires when the user renames a tab. Manager forwards to the layout-persist + /// sink so a custom title survives relaunch without waiting for quit. + var onTabRenamed: (() -> Void)? + var onFocusChanged: ((UUID) -> Void)? + // Fired when the currently focused surface's background color changes (OSC 11). + var onFocusedSurfaceColorChanged: (() -> Void)? + var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? + var onBlockingScriptCompleted: ((BlockingScriptKind, Int?, TerminalTabID?) -> Void)? + /// Fires (coalesced, next tick) on any `blockingScripts` mutation; the + /// manager re-emits the Equatable-diffed row projection so TCA reconciles + /// to terminal truth. + var onRunningScriptsChanged: (() -> Void)? + var onCommandPaletteToggle: (() -> Void)? + var onSetupScriptConsumed: (() -> Void)? + /// Forwarded to the manager so it can emit a `surfacesClosed` event into TCA. + var onSurfacesClosed: ((Set) -> Void)? + /// Forwarded to the manager's `dispatchHookEvent` so an OSC-sourced presence + /// event joins the same funnel as the socket path (idle-debounce, badge). + var onAgentHookEvent: ((AgentHookEvent) -> Void)? + /// Fires when a tab's per-tab projection (surfaces / focus / unseen count) + /// drifts. Manager forwards into `TerminalTabFeature.State` via + /// `tabProjectionChanged` so the leaf observes a per-tab store. + var onTabProjectionChanged: ((WorktreeTabProjection) -> Void)? + /// Fires when a tab is fully removed (closeTab, closeAll). Manager forwards + /// so the parent reducer drops the corresponding `TerminalTabFeature.State`. + var onTabRemoved: ((TerminalTabID) -> Void)? + /// Fires when a tab's stripe-progress display drifts. Computed off the + /// active surface (selected tab) or worst-of-all (unselected tabs) so the + /// stripe stays in lock-step with focus and OSC-9 progress mutations. + var onTabProgressDisplayChanged: ((TerminalTabID, TerminalTabProgressDisplay?) -> Void)? + + init( + runtime: GhosttyRuntime, + worktree: Worktree, + runSetupScript: Bool = false, + splitPreserveZoomOnNavigation: (() -> Bool)? = nil + ) { + self.runtime = runtime + self.splitPreserveZoomOnNavigation = splitPreserveZoomOnNavigation ?? { runtime.splitPreserveZoomOnNavigation() } + self.worktree = worktree + self.terminal = TerminalSurfaceStore(worktree: worktree, pendingSetupScript: runSetupScript) + self.tabManager = TerminalTabManager() + _repositorySettings = SharedReader( + wrappedValue: RepositorySettings.default, + .repositorySettings(worktree.repositoryRootURL, host: worktree.host) + ) + // Pre-hide the tab bar before the first tab is created to + // avoid a visible flash. updateShouldHideTabBar() handles + // the steady state once tabs exist. + @Shared(.settingsFile) var settingsFile + self.shouldHideTabBar = settingsFile.global.hideSingleTabBar + dragCoordinator.onDrop = { [weak self] payloadID, destinationID, zone in + guard let self, let tabId = self.tabID(containing: destinationID) else { return } + self.performSplitOperation( + .drop(payloadId: payloadID, destinationId: destinationID, zone: zone), in: tabId) + } + } + + var taskStatus: WorktreeTaskStatus { + trees.keys.contains(where: { isTabBusy($0) }) ? .running : .idle + } + + private func isTabBusy(_ tabId: TerminalTabID) -> Bool { + guard let tree = trees[tabId] else { return false } + return tree.leaves().contains { leaf in + switch leaf.content { + case .terminal(let surface): isRunningProgressState(surface.bridge.state.progressState) + } + } + } + + /// Per-row projection consumed by `SidebarItemFeature.terminalProjectionChanged`. + /// `isProgressBusy` reflects Ghostty progress state only; AppFeature merges + /// agent activity downstream of this event. + func currentProjection() -> WorktreeRowProjection { + WorktreeRowProjection( + surfaceIDs: allSurfaceIDs, + isProgressBusy: taskStatus == .running, + hasUnseenNotifications: hasUnseenNotification, + notifications: IdentifiedArray(uniqueElements: notifications), + runningScripts: runningScriptsProjection(), + ) + } + + /// Order-stable snapshot of the user scripts currently tracked in + /// `blockingScripts`; lifecycle kinds (archive / delete) carry no + /// definition ID and are excluded by construction. + private func runningScriptsProjection() -> IdentifiedArrayOf { + var scripts: IdentifiedArrayOf = [] + let definitions = blockingScripts.values + .compactMap { kind -> ScriptDefinition? in + guard case .script(let definition) = kind else { return nil } + return definition + } + .sorted { $0.id.uuidString < $1.id.uuidString } + for definition in definitions { + scripts.updateOrAppend(.init(id: definition.id, tint: definition.resolvedTintColor)) + } + return scripts + } + + private func scheduleRunningScriptsProjectionEmit() { + guard !pendingRunningScriptsProjectionEmit else { return } + pendingRunningScriptsProjectionEmit = true + Task { @MainActor [weak self] in + guard let self else { return } + self.pendingRunningScriptsProjectionEmit = false + self.onRunningScriptsChanged?() + } + } + + func isBlockingScriptRunning(kind: BlockingScriptKind) -> Bool { + blockingScripts.values.contains(kind) + } + + var hasInflightBlockingScripts: Bool { + !blockingScripts.isEmpty + } + + private func updateShouldHideTabBar() { + @Shared(.settingsFile) var settingsFile + // Force the bar visible on a split-zoomed single tab so the dismiss-zoom indicator has somewhere to live. + let wouldHide = settingsFile.global.hideSingleTabBar && tabManager.tabs.count == 1 + let newValue = wouldHide && !trees.values.contains { $0.zoomed != nil } + guard shouldHideTabBar != newValue else { return } + shouldHideTabBar = newValue + } + + func refreshTabBarVisibility() { + updateShouldHideTabBar() + } + + func isSplitZoomed(forTabID tabID: TerminalTabID) -> Bool { + trees[tabID]?.zoomed != nil + } + + func dismissSplitZoom(for tabID: TerminalTabID) { + guard let tree = trees[tabID], let zoomed = tree.zoomed else { return } + let previouslyZoomedSurface = zoomed.leftmostLeaf() + updateTree(tree.settingZoomed(nil), for: tabID) + focusLeaf(previouslyZoomedSurface, in: tabID) + } + + func ensureInitialTab(focusing: Bool) { + guard !hasAttemptedInitialTab else { return } + hasAttemptedInitialTab = true + guard tabManager.tabs.isEmpty else { return } + + if let snapshot = pendingLayoutSnapshot { + pendingLayoutSnapshot = nil + restoreFromSnapshot(snapshot, focusing: focusing) + return + } + let setupScript = pendingSetupScript ? repositorySettings.setupScript : nil + _ = createTab(focusing: focusing, setupScript: setupScript) + } + + @discardableResult + func createTab( + focusing: Bool = true, + setupScript: String? = nil, + initialInput: String? = nil, + inheritingFromSurfaceId: UUID? = nil, + tabID: UUID? = nil + ) -> TerminalTabID? { + let context: ghostty_surface_context_e = + tabManager.tabs.isEmpty + ? GHOSTTY_SURFACE_CONTEXT_WINDOW + : GHOSTTY_SURFACE_CONTEXT_TAB + let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() + let title = "\(worktree.name) \(nextTabIndex())" + let setupInput = terminal.setupScriptInput(setupScript: setupScript) + let commandInput = initialInput.flatMap { BlockingScriptRunner.makeCommandInput(script: $0) } + let resolvedInput: String? + switch (setupInput, commandInput) { + case (nil, nil): + resolvedInput = nil + case (let setupInput?, nil): + resolvedInput = setupInput + case (nil, let commandInput?): + resolvedInput = commandInput + case (let setupInput?, let commandInput?): + resolvedInput = setupInput + commandInput + } + let shouldConsumeSetupScript = pendingSetupScript && setupScript != nil + if shouldConsumeSetupScript { + pendingSetupScript = false + } + let tabId = createTab( + TabCreation( + title: title, + icon: nil, + isTitleLocked: false, + command: nil, + initialInput: resolvedInput, + focusing: focusing, + inheritingFromSurfaceId: resolvedInheritanceSurfaceId, + context: context, + tabID: tabID, + ) + ) + if shouldConsumeSetupScript, tabId != nil { + onSetupScriptConsumed?() + } + return tabId + } + + /// Stops a single user-defined script identified by its definition ID. + @discardableResult + func stopScript(definitionID: UUID) -> Bool { + guard + let tabId = blockingScripts.first(where: { $0.value.scriptDefinitionID == definitionID })?.key + else { return false } + closeTab(tabId) + return true + } + + /// Stops all running `.run`-kind scripts. Intentionally excludes + /// non-run scripts (test, deploy, etc.) because the Stop action + /// (Cmd+.) is the semantic counterpart of Run, not a "stop + /// everything" command. Other kinds are stopped individually + /// via the script menu or command palette. + @discardableResult + func stopRunScripts() -> Bool { + let runTabIds = blockingScripts.filter { $0.value.isRunKind }.map(\.key) + guard !runTabIds.isEmpty else { return false } + for tabId in runTabIds { + closeTab(tabId) + } + return true + } + + /// Returns the set of script definition IDs currently running. + func runningScriptDefinitionIDs() -> Set { + Set(blockingScripts.values.compactMap(\.scriptDefinitionID)) + } + + /// Checks whether a user-defined script with the given definition ID is running. + func isScriptRunning(definitionID: UUID) -> Bool { + blockingScripts.values.contains(where: { $0.scriptDefinitionID == definitionID }) + } + + @discardableResult + func runBlockingScript(kind: BlockingScriptKind, _ script: String) -> TerminalTabID? { + // A re-run of an already-tracked user script is a duplicate request, not a + // restart: keep the running instance (#573). Lifecycle kinds (archive / + // delete) keep their replace-on-rerun semantics. + if case .script = kind, + let active = blockingScripts.first(where: { $0.value == kind })?.key + { + // The early return skips the `blockingScripts` didSet, so emit explicitly + // to unstick a row whose projection was shed or stripped. + scheduleRunningScriptsProjectionEmit() + return active + } + // Resolve the surface command per host. A remote worktree runs the same + // OSC 133 framing on the host over ssh (no local temp files, no zmx wrap), + // so the script executes on the remote and not on a same-path local dir. + let command: String + let initialInput: String? + let launchDirectory: URL? + if let host = worktree.host { + guard + let remote = BlockingScriptRunner.remoteCommand( + host: host, + script: script, + remoteWorktreePath: worktree.workingDirectory.path(percentEncoded: false), + environment: terminal.blockingScriptEnvironment(for: kind) + ) + else { + reportBlockingScriptLaunchFailure(kind, "Failed to build remote \(kind.tabTitle) for worktree \(worktree.id)") + return nil + } + command = remote + initialInput = nil + launchDirectory = nil + } else { + let launch: BlockingScriptRunner.LaunchArtifacts + do { + guard let prepared = try terminal.blockingScriptLaunch(script) else { + reportBlockingScriptLaunchFailure( + kind, "Failed to prepare \(kind.tabTitle) for worktree \(worktree.id): empty script") + return nil + } + launch = prepared + } catch { + reportBlockingScriptLaunchFailure( + kind, "Failed to prepare \(kind.tabTitle) for worktree \(worktree.id): \(error)") + return nil + } + command = defaultShellPath() + initialInput = launch.commandInput + launchDirectory = launch.directoryURL + } + // Close any previous tab of the same kind: lingering from a completed or + // cancelled run, or (lifecycle kinds only) still active. Clear tracking + // state first so closeTab doesn't fire a premature completion callback. + if let active = blockingScripts.first(where: { $0.value == kind })?.key { + blockingScripts.removeValue(forKey: active) + lastBlockingScriptTabByKind.removeValue(forKey: kind) + closeTab(active) + } else if let lingering = lastBlockingScriptTabByKind.removeValue(forKey: kind) { + closeTab(lingering) + } + let tabId = createTab( + TabCreation( + title: kind.tabTitle, + icon: kind.tabIcon, + isTitleLocked: true, + tintColor: kind.tabColor, + command: command, + initialInput: initialInput, + focusing: true, + inheritingFromSurfaceId: currentFocusedSurfaceId(), + context: GHOSTTY_SURFACE_CONTEXT_TAB, + tabID: nil, + isBlockingScript: true, + blockingScriptKind: kind, + bypassZmx: true, + ) + ) + guard let tabId else { + if let launchDirectory { + terminal.cleanupBlockingScriptLaunchDirectory(at: launchDirectory) + } + reportBlockingScriptLaunchFailure(kind, "Failed to create \(kind.tabTitle) tab for worktree \(worktree.id)") + return nil + } + if let launchDirectory { + terminal.blockingScriptLaunchDirectories[tabId] = launchDirectory + } + lastBlockingScriptTabByKind[kind] = tabId + tabManager.updateDirty(tabId, isDirty: true) + emitTaskStatusIfChanged() + + blockingScriptLogger.info("Started \(kind.tabTitle) for worktree \(worktree.id)") + return tabId + } + + /// Report a launch that never produced a tab: exit 1 and no tab id, so the + /// caller gets an alert and a completion instead of a silent nil (#573). + private func reportBlockingScriptLaunchFailure(_ kind: BlockingScriptKind, _ message: String) { + blockingScriptLogger.warning(message) + onBlockingScriptCompleted?(kind, 1, nil) + } + + private struct TabCreation: Equatable { + let title: String + let icon: String? + let isTitleLocked: Bool + var tintColor: RepositoryColor? + let command: String? + let initialInput: String? + let focusing: Bool + let inheritingFromSurfaceId: UUID? + let context: ghostty_surface_context_e + let tabID: UUID? + /// Marks the tab as a blocking-script tab so the no-split / no-rename + /// / readonly-after-completion guardrails apply. + var isBlockingScript: Bool = false + /// The blocking-script kind, recorded into `blockingScripts` before the + /// surface is built so `surfaceEnvironment` can emit its env markers. + var blockingScriptKind: BlockingScriptKind? + /// Skip zmx session wrapping for transactional surfaces (blocking setup/archive/delete scripts) + /// that must die with the app rather than survive. + var bypassZmx: Bool = false + } + + private func createTab(_ creation: TabCreation) -> TerminalTabID? { + let tabId = tabManager.createTab( + title: creation.title, + icon: creation.icon, + isTitleLocked: creation.isTitleLocked, + tintColor: creation.tintColor, + isBlockingScript: creation.isBlockingScript, + id: creation.tabID, + ) + // Record the kind before the surface is built so `surfaceEnvironment` + // can read it when emitting the blocking-script env markers. + if let blockingScriptKind = creation.blockingScriptKind { + blockingScripts[tabId] = blockingScriptKind + } + // When a tab ID is explicitly provided, use it as the initial surface ID + // so the CLI can reference the surface immediately after creation. + let tree = splitTree( + for: tabId, + inheritingFromSurfaceId: creation.inheritingFromSurfaceId, + command: creation.command, + initialInput: creation.initialInput, + context: creation.context, + surfaceID: creation.tabID != nil ? tabId.rawValue : nil, + bypassZmx: creation.bypassZmx + ) + updateShouldHideTabBar() + if creation.focusing, let leaf = tree.root?.leftmostLeaf() { + focusLeaf(leaf, in: tabId) + } + onTabCreated?() + return tabId + } + + func listSurfaces(tabID: TerminalTabID) -> [[String: String]] { + let focusedID = focusedSurfaceIdByTab[tabID] + return surfaces.compactMap { surfaceID, _ in + guard self.tabID(containing: surfaceID) == tabID else { return nil } + var entry = ["id": surfaceID.uuidString] + if surfaceID == focusedID { entry["focused"] = "1" } + return entry + }.sorted { ($0["id"] ?? "") < ($1["id"] ?? "") } + } + + func hasTab(_ tabId: TerminalTabID) -> Bool { + tabManager.tabs.contains(where: { $0.id == tabId }) + } + + /// Surface IDs in a single tab (one entry per leaf of the tab's split tree). + /// Empty if the tab does not exist. + func surfaceIDs(inTab tabId: TerminalTabID) -> [UUID] { + trees[tabId]?.leaves().map(\.id) ?? [] + } + + /// All surface IDs across every tab in this worktree state. + var allSurfaceIDs: [UUID] { + trees.values.flatMap { $0.leaves().map(\.id) } + } + + /// Host of a remote worktree, nil for local. Every surface in this state + /// shares it, so teardown paths can target the host-side zmx sessions. + var remoteHost: RemoteHost? { + worktree.host + } + + // Standardized to match `loadFailuresByID` keys (built from `standardizedFileURL.path`) + // so prune protection lines up. + var repositoryID: Repository.ID { + switch worktree.location.repositoryLocation { + case .local(let url): + RepositoryID(url.standardizedFileURL.path(percentEncoded: false)) + case .remote: + worktree.location.repositoryLocation.id + } + } + + /// O(1) emptiness check that skips the split-tree walk in `allSurfaceIDs`. + var hasAnySurface: Bool { !surfaces.isEmpty } + + func hasSurface(_ surfaceID: UUID, in tabId: TerminalTabID) -> Bool { + guard let tree = trees[tabId] else { return false } + return tree.find(id: surfaceID) != nil + } + + /// Checks whether a surface UUID exists anywhere in the worktree (across all tabs). + func hasSurfaceAnywhere(_ surfaceID: UUID) -> Bool { + surfaces[surfaceID] != nil + } + + func selectTab(_ tabId: TerminalTabID) { + guard tabManager.tabs.contains(where: { $0.id == tabId }) else { + terminalStateLogger.warning("selectTab: tab \(tabId.rawValue) not found in worktree \(worktree.id).") + return + } + let previousSelectedTabId = tabManager.selectedTabId + tabManager.selectTab(tabId) + focusSurface(in: tabId) + // Re-emit the stripe progress for both old and new selected tabs: their + // "focused vs aggregate" branch just flipped. + if let previousSelectedTabId, previousSelectedTabId != tabId { + emitTabProgressDisplay(for: previousSelectedTabId) + } + emitTabProgressDisplay(for: tabId) + emitTaskStatusIfChanged() + } + + func focusSelectedTab() { + guard let tabId = tabManager.selectedTabId else { return } + focusSurface(in: tabId) + } + + func focusAndInsertText(_ text: String) { + guard let tabId = tabManager.selectedTabId, + let focusedId = focusedSurfaceIdByTab[tabId], + let surface = surfaces[focusedId] + else { + terminalStateLogger.warning("focusAndInsertText: no focused surface") + return + } + terminalStateLogger.info("focusAndInsertText: sending \(text.count) chars to surface \(focusedId)") + surface.requestFocus() + surface.sendText(text) + } + + func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { + lastWindowIsKey = windowIsKey + lastWindowIsVisible = windowIsVisible + applySurfaceActivity() + } + + private func applySurfaceActivity() { + let selectedTabId = tabManager.selectedTabId + var surfaceToFocus: GhosttySurfaceView? + for (tabId, tree) in trees { + let focusedId = focusedSurfaceIdByTab[tabId] + let isSelectedTab = (tabId == selectedTabId) + let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) + for leaf in tree.leaves() { + switch leaf.content { + case .terminal(let surface): + let activity = Self.surfaceActivity( + isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), + isSelectedTab: isSelectedTab, + windowIsVisible: lastWindowIsVisible == true, + windowIsKey: lastWindowIsKey == true, + focusedSurfaceID: focusedId, + surfaceID: surface.id + ) + surface.setOcclusion(activity.isVisible) + surface.focusDidChange(activity.isFocused) + if activity.isFocused { + surfaceToFocus = surface + } + } + } + } + if let surfaceToFocus, surfaceToFocus.window?.firstResponder is GhosttySurfaceView { + surfaceToFocus.window?.makeFirstResponder(surfaceToFocus) + } + } + + static func surfaceActivity( + isSurfaceVisibleInTree: Bool = true, + isSelectedTab: Bool, + windowIsVisible: Bool, + windowIsKey: Bool, + focusedSurfaceID: UUID?, + surfaceID: UUID + ) -> SurfaceActivity { + let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible + let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID + return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) + } + + @discardableResult + func focusSurface(id: UUID) -> Bool { + guard let tabId = tabID(containing: id), + let surface = surfaces[id] + else { + terminalStateLogger.warning("focusSurface: surface \(id) not found in worktree \(worktree.id).") + return false + } + tabManager.selectTab(tabId) + focusSurface(surface, in: tabId) + return true + } + + @discardableResult + func closeFocusedTab() -> Bool { + guard let tabId = tabManager.selectedTabId else { return false } + closeTab(tabId) + return true + } + + @discardableResult + func closeFocusedSurface() -> Bool { + guard let tabId = tabManager.selectedTabId, + let focusedId = focusedSurfaceIdByTab[tabId], + let surface = surfaces[focusedId] + else { + return false + } + requestExplicitSurfaceClose(surface) + return true + } + + @discardableResult + func closeSurface(id surfaceID: UUID) -> Bool { + guard let surface = surfaces[surfaceID] else { + terminalStateLogger.warning( + "closeSurface: surface \(surfaceID) not found. Known: \(surfaces.keys.map(\.uuidString))") + return false + } + requestExplicitSurfaceClose(surface) + return true + } + + private func requestExplicitSurfaceClose(_ surface: GhosttySurfaceView) { + performBindingAction("close_surface", on: surface) + } + + @discardableResult + func performBindingActionOnFocusedSurface(_ action: String) -> Bool { + guard let tabId = tabManager.selectedTabId, + let focusedId = focusedSurfaceIdByTab[tabId], + let surface = surfaces[focusedId] + else { + return false + } + performBindingAction(action, on: surface) + return true + } + + @discardableResult + func performBindingAction(_ action: String, onSurfaceID surfaceID: UUID) -> Bool { + guard let surface = surfaces[surfaceID] else { return false } + performBindingAction(action, on: surface) + return true + } + + @discardableResult + func setImagePasteAgents(_ agents: Set, onSurfaceID surfaceID: UUID) -> Bool { + guard let surface = surfaces[surfaceID] else { return false } + surface.imagePasteAgents = agents + return true + } + + private func performBindingAction(_ action: String, on surface: GhosttySurfaceView) { + if action == "close_surface" { + pendingExplicitSurfaceCloseIDs.insert(surface.id) + } + surface.performBindingAction(action) + } + + @discardableResult + func navigateSearchOnFocusedSurface(_ direction: GhosttySearchDirection) -> Bool { + guard let tabId = tabManager.selectedTabId, + let focusedId = focusedSurfaceIdByTab[tabId], + let surface = surfaces[focusedId] + else { + return false + } + surface.navigateSearch(direction) + return true + } + + func closeTab(_ tabId: TerminalTabID) { + let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) + terminal.cleanupBlockingScriptLaunchDirectory(for: tabId) + // Clear lingering tab tracking for completed or non-blocking tabs. + for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { + lastBlockingScriptTabByKind.removeValue(forKey: kind) + } + removeTree(for: tabId) + tabManager.closeTab(tabId) + updateShouldHideTabBar() + if let selected = tabManager.selectedTabId { + focusSurface(in: selected) + } else { + lastEmittedFocusSurfaceId = nil + } + emitTaskStatusIfChanged() + + if let closedBlockingKind { + blockingScriptLogger.info("\(closedBlockingKind.tabTitle) cancelled (tab closed)") + onBlockingScriptCompleted?(closedBlockingKind, nil, nil) + } + onTabClosed?() + } + + /// User-initiated rename. Routes through the manager so the new title (or its + /// removal on an empty commit) persists incrementally, unlike the restore path + /// which seeds `setCustomTitle` directly from a snapshot. + func renameTab(_ tabId: TerminalTabID, title: String) { + tabManager.setCustomTitle(tabId, title: title) + onTabRenamed?() + } + + func closeOtherTabs(keeping tabId: TerminalTabID) { + let ids = tabManager.tabs.map(\.id).filter { $0 != tabId } + for id in ids { + closeTab(id) + } + } + + func closeTabsToRight(of tabId: TerminalTabID) { + guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } + let ids = tabManager.tabs.dropFirst(index + 1).map(\.id) + for id in ids { + closeTab(id) + } + } + + func closeAllTabs() { + let ids = tabManager.tabs.map(\.id) + for id in ids { + closeTab(id) + } + } + + func splitTree( + for tabId: TerminalTabID, + inheritingFromSurfaceId: UUID? = nil, + command: String? = nil, + initialInput: String? = nil, + context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB, + surfaceID: UUID? = nil, + bypassZmx: Bool = false + ) -> SplitTree { + if let existing = trees[tabId] { + return existing + } + // A stale render of a just-closed tab (removal transition) must not lazily + // resurrect it: the replacement surface would be invisible, unclosable, and + // hold its local and host zmx sessions alive. + guard hasTab(tabId) else { return SplitTree() } + let surface = createSurface( + tabId: tabId, + command: command, + initialInput: initialInput, + inheritingFromSurfaceId: inheritingFromSurfaceId, + context: context, + surfaceID: surfaceID, + bypassZmx: bypassZmx + ) + let tree = SplitTree(view: surface) + setTree(tree, for: tabId) + setFocusedSurface(surface.id, for: tabId) + return tree + } + + func performSplitAction( + _ action: GhosttySplitAction, + for surfaceID: UUID, + newSurfaceID: UUID? = nil, + initialInput: String? = nil + ) -> Bool { + guard let tabId = tabID(containing: surfaceID), var tree = trees[tabId] else { + return false + } + guard let targetNode = tree.find(id: surfaceID) else { return false } + guard let targetSurface = surfaces[surfaceID] else { return false } + + switch action { + case .newSplit(let direction): + // Splits would leak a zmx-wrapped sibling into a transactional tab. + // Refuse before allocating a surface so the tab stays single-pane. + if tabManager.isBlockingScript(tabId) { + return false + } + let newSurface = createSurface( + tabId: tabId, + initialInput: initialInput, + inheritingFromSurfaceId: surfaceID, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + surfaceID: newSurfaceID, + ) + do { + let newTree = try tree.inserting( + view: newSurface, + at: targetSurface, + direction: mapSplitDirection(direction) + ) + updateTree(newTree, for: tabId) + focusSurface(newSurface, in: tabId) + return true + } catch { + terminalStateLogger.warning( + "performSplitAction: failed to insert split for surface \(surfaceID) in tab \(tabId.rawValue): \(error)") + newSurface.closeSurface() + discardSurfaceBookkeeping(for: newSurface.id) + return false + } + + case .gotoSplit(let direction): + let focusDirection = mapFocusDirection(direction) + guard let nextSurface = tree.focusTarget(for: focusDirection, from: targetNode) else { + return false + } + if tree.zoomed != nil { + if splitPreserveZoomOnNavigation() { + let nextNode = tree.root?.node(view: nextSurface) + tree = tree.settingZoomed(nextNode) + } else { + tree = tree.settingZoomed(nil) + } + updateTree(tree, for: tabId) + } + focusLeaf(nextSurface, in: tabId) + syncFocusIfNeeded() + return true + + case .resizeSplit(let direction, let amount): + let spatialDirection = mapResizeDirection(direction) + do { + let newTree = try tree.resizing( + node: targetNode, + by: amount, + in: spatialDirection, + with: CGRect(origin: .zero, size: tree.viewBounds()) + ) + updateTree(newTree, for: tabId) + return true + } catch { + return false + } + + case .equalizeSplits: + updateTree(tree.equalized(), for: tabId) + return true + + case .toggleSplitZoom: + guard tree.isSplit else { return false } + let newZoomed = (tree.zoomed == targetNode) ? nil : targetNode + updateTree(tree.settingZoomed(newZoomed), for: tabId) + focusSurface(targetSurface, in: tabId) + return true + } + } + + func performSplitOperation(_ operation: TerminalSplitTreeView.Operation, in tabId: TerminalTabID) { + guard var tree = trees[tabId] else { return } + // Drag-to-drop surfaces from other tabs into a blocking-script tab would + // introduce a zmx-wrapped sibling. Same rationale as the `newSplit` guard. + if case .drop = operation, tabManager.isBlockingScript(tabId) { return } + + switch operation { + case .resize(let node, let ratio): + let resizedNode = node.resizing(to: ratio) + do { + tree = try tree.replacing(node: node, with: resizedNode) + updateTree(tree, for: tabId) + } catch { + return + } + + case .drop(let payloadId, let destinationId, let zone): + // Resolve through the tab's tree (content-agnostic), not the terminal-only + // `surfaces` map: a drop only ever rearranges leaves within this tab. + let leaves = tree.visibleLeaves() + guard let payload = leaves.first(where: { $0.id == payloadId }), + let destination = leaves.first(where: { $0.id == destinationId }), + payload !== destination, + let sourceNode = tree.root?.node(view: payload) + else { return } + let treeWithoutSource = tree.removing(sourceNode) + if treeWithoutSource.isEmpty { return } + do { + let newTree = try treeWithoutSource.inserting( + view: payload, + at: destination, + direction: mapDropZone(zone) + ) + updateTree(newTree, for: tabId) + focusLeaf(payload, in: tabId) + } catch { + return + } + + case .equalize: + updateTree(tree.equalized(), for: tabId) + } + } + + func setAllSurfacesOccluded() { + for surface in surfaces.values { + surface.setOcclusion(false) + surface.focusDidChange(false) + } + } + + func closeAllSurfaces() { + let closingSurfaces = Array(surfaces.values) + let closingSurfaceIDs = closingSurfaces.map(\.id) + for surface in closingSurfaces { + surface.closeSurface() + } + for surfaceID in closingSurfaceIDs { + discardSurfaceBookkeeping(for: surfaceID) + } + terminal.cleanupBlockingScriptLaunchDirectories() + trees.removeAll() + surfaceGenerationByTab.removeAll() + focusedSurfaceIdByTab.removeAll() + onSurfacesClosed?(Set(closingSurfaceIDs)) + let pendingKinds = Set(blockingScripts.values) + blockingScripts.removeAll() + lastBlockingScriptTabByKind.removeAll() + + for kind in pendingKinds { + onBlockingScriptCompleted?(kind, nil, nil) + } + tabManager.closeAll() + // Drain per-tab caches and notify so `TerminalsFeature.State.terminalTabs` + // entries don't leak for tabs in a torn-down worktree (#289 follow-up). + let removedTabIDs = Array(lastTabProjections.keys) + lastTabProjections.removeAll() + lastTabProgressDisplays.removeAll() + for tabID in removedTabIDs { + onTabRemoved?(tabID) + } + } + + func setNotificationsEnabled(_ enabled: Bool) { + notificationsEnabled = enabled + if !enabled { + markAllNotificationsRead() + } + } + + func clearNotificationIndicator() { + markAllNotificationsRead() + } + + func markAllNotificationsRead() { + for index in notifications.indices { + notifications[index].isRead = true + } + clearAllSurfaceUnseenFlags() + emitAllTabProjections() + emitNotificationStateChanged() + } + + func markNotificationsRead(forSurfaceID surfaceID: UUID) { + for index in notifications.indices where notifications[index].surfaceID == surfaceID { + notifications[index].isRead = true + } + setSurfaceUnseenFlag(surfaceID, to: false) + if let tabId = tabID(containing: surfaceID) { + emitTabProjection(for: tabId) + } + emitNotificationStateChanged() + } + + /// Marks a single notification as read, leaving others untouched. + func markNotificationRead(id: WorktreeTerminalNotification.ID) { + guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } + guard !notifications[index].isRead else { return } + let surfaceID = notifications[index].surfaceID + notifications[index].isRead = true + refreshSurfaceUnseenFlag(surfaceID) + if let tabId = tabID(containing: surfaceID) { + emitTabProjection(for: tabId) + } + emitNotificationStateChanged() + } + + func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { + let affectedSurface = notifications.first(where: { $0.id == notificationID })?.surfaceID + notifications.removeAll { $0.id == notificationID } + if let affectedSurface { + refreshSurfaceUnseenFlag(affectedSurface) + if let tabId = tabID(containing: affectedSurface) { + emitTabProjection(for: tabId) + } + } + emitNotificationStateChanged() + } + + func dismissAllNotifications() { + notifications.removeAll() + clearAllSurfaceUnseenFlags() + emitAllTabProjections() + emitNotificationStateChanged() + } + + /// Recomputes the surface's unseen flag through the canonical predicate so a + /// future tweak to `hasUnseenNotification(forSurfaceID:)` is picked up here + /// without a parallel branch silently drifting. + private func refreshSurfaceUnseenFlag(_ surfaceID: UUID) { + setSurfaceUnseenFlag(surfaceID, to: hasUnseenNotification(forSurfaceID: surfaceID)) + } + + private func setSurfaceUnseenFlag(_ surfaceID: UUID, to value: Bool) { + guard let state = surfaceStates[surfaceID] else { return } + guard state.hasUnseenNotification != value else { return } + state.hasUnseenNotification = value + } + + private func clearAllSurfaceUnseenFlags() { + for state in surfaceStates.values where state.hasUnseenNotification { + state.hasUnseenNotification = false + } + } + + // MARK: - Layout Snapshot + + /// Capture a layout snapshot, optionally embedding per-surface agent + /// presence records. The caller (AppDelegate's `applicationWillTerminate` + /// path) reads `AppFeature.State.agentPresence.records` and converts it + /// into the per-surface dict before invoking this so agents persist + /// atomically with their owning surface and vanish on prune. + func captureLayoutSnapshot( + agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] = [:] + ) -> TerminalLayoutSnapshot? { + guard !tabManager.tabs.isEmpty else { return nil } + var tabSnapshots: [TerminalLayoutSnapshot.TabSnapshot] = [] + for tab in tabManager.tabs { + // Blocking-script tabs die with the app; persisting them would resurrect a dead session. + if tab.isBlockingScript { continue } + guard let tree = trees[tab.id], let root = tree.root else { + layoutLogger.warning("Skipping tab \(tab.id.rawValue) during snapshot capture (no tree)") + continue + } + let layout = captureLayoutNode(root, agentsBySurface: agentsBySurface) + let leaves = root.leaves() + let focusedId = focusedSurfaceIdByTab[tab.id] + let focusedLeafIndex = + focusedId.flatMap { id in + leaves.firstIndex(where: { $0.id == id }) + } ?? 0 + tabSnapshots.append( + TerminalLayoutSnapshot.TabSnapshot( + id: tab.id.rawValue, + title: tab.title, + customTitle: tab.customTitle, + icon: tab.icon, + tintColor: tab.tintColor, + layout: layout, + focusedLeafIndex: focusedLeafIndex, + ) + ) + } + guard !tabSnapshots.isEmpty else { return nil } + // Walk against the surviving tabs (post-filter), preferring the nearest + // left neighbor when the originally-selected tab was excluded. If every + // left neighbor is also excluded, fall through to the leftmost surviving + // tab. Computing against `tabManager.tabs` would land on the wrong + // neighbor for `[A, B(blocking, selected), C]`. + let selectedIndex: Int = { + guard let selectedID = tabManager.selectedTabId else { return 0 } + if let direct = tabSnapshots.firstIndex(where: { $0.id == selectedID.rawValue }) { + return direct + } + guard let originalIndex = tabManager.tabs.firstIndex(where: { $0.id == selectedID }) else { + return 0 + } + for index in stride(from: originalIndex - 1, through: 0, by: -1) { + let candidate = tabManager.tabs[index] + if let surviving = tabSnapshots.firstIndex(where: { $0.id == candidate.id.rawValue }) { + return surviving + } + } + return 0 + }() + return TerminalLayoutSnapshot(tabs: tabSnapshots, selectedTabIndex: selectedIndex) + } + + private func captureLayoutNode( + _ node: SplitTree.Node, + agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] + ) -> TerminalLayoutSnapshot.LayoutNode { + switch node { + case .leaf(let view): + switch view.content { + case .terminal(let surface): + return .leaf( + .terminal( + id: surface.id, + workingDirectory: surface.bridge.state.pwd, + agents: agentsBySurface[surface.id] + ) + ) + } + case .split(let split): + let direction: SplitDirection = + switch split.direction { + case .horizontal: .horizontal + case .vertical: .vertical + } + return .split( + TerminalLayoutSnapshot.SplitSnapshot( + direction: direction, + ratio: split.ratio, + left: captureLayoutNode(split.left, agentsBySurface: agentsBySurface), + right: captureLayoutNode(split.right, agentsBySurface: agentsBySurface) + ) + ) + } + } + + private func restoreFromSnapshot(_ snapshot: TerminalLayoutSnapshot, focusing: Bool) { + guard !snapshot.tabs.isEmpty else { + layoutLogger.warning("Attempted to restore empty layout snapshot, skipping restoration.") + return + } + + // Skip setup script when restoring a saved layout. + pendingSetupScript = false + + for (index, tabSnapshot) in snapshot.tabs.enumerated() { + // Kind dispatch on the persisted leaf; a new snapshot kind adds a case. + let firstLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot + switch tabSnapshot.layout.firstLeaf { + case .terminal(let leaf): firstLeaf = leaf + } + let workingDir = firstLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } + let context: ghostty_surface_context_e = + index == 0 ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB + let tabId = tabManager.createTab( + title: tabSnapshot.title, + icon: tabSnapshot.icon, + isTitleLocked: false, + tintColor: tabSnapshot.tintColor, + id: tabSnapshot.id, + ) + if let customTitle = tabSnapshot.customTitle { + tabManager.setCustomTitle(tabId, title: customTitle) + } + let surface = createSurface( + tabId: tabId, + initialInput: nil, + workingDirectoryOverride: workingDir, + inheritingFromSurfaceId: nil, + context: context, + surfaceID: firstLeaf.id, + ) + let tree = SplitTree(view: surface) + setTree(tree, for: tabId) + setFocusedSurface(surface.id, for: tabId) + + // Recursively restore splits. + restoreLayoutNode(tabSnapshot.layout, anchor: surface, tabId: tabId) + + // Log if partial restoration produced fewer panes than expected. + let leaves = trees[tabId]?.root?.leaves() ?? [] + let expectedLeaves = tabSnapshot.layout.leafCount + if leaves.count != expectedLeaves { + layoutLogger.warning( + "Partial restore for tab '\(tabSnapshot.title)': expected \(expectedLeaves) panes, got \(leaves.count)" + ) + } + + // Focus the correct leaf. + let focusedIndex = max(0, min(tabSnapshot.focusedLeafIndex, leaves.count - 1)) + if focusedIndex < leaves.count { + setFocusedSurface(leaves[focusedIndex].id, for: tabId) + } + + onTabCreated?() + } + + // Seed image-paste routing from the snapshot's per-surface agent records, matching + // the presence restore's liveness filter: an unopened worktree drains its rehydrate + // fan-out before the surface exists, and a dead-pid record never gets a corrective + // empty fan-out, so seeding only live agents keeps Cmd+V from routing into a stale shell. + for record in snapshot.allAgentRecords() { + let liveAgents = record.records.compactMap { + $0.pids.contains(where: AgentPresenceFeature.isAlive) ? SkillAgent(rawValue: $0.agent) : nil + } + guard !liveAgents.isEmpty else { continue } + surfaces[record.surfaceID]?.imagePasteAgents = Set(liveAgents) + } + + // Select the correct tab. + let selectedIndex = max(0, min(snapshot.selectedTabIndex, tabManager.tabs.count - 1)) + if selectedIndex < tabManager.tabs.count { + let selectedTab = tabManager.tabs[selectedIndex] + tabManager.selectTab(selectedTab.id) + if focusing { + focusSurface(in: selectedTab.id) + } + } + + // Notifications outlive surfaces, so re-derive the freshly minted + // `SurfaceIndicatorState` flags or the per-surface dot stays dark after restore. + for surfaceID in Set(notifications.map(\.surfaceID)) { + refreshSurfaceUnseenFlag(surfaceID) + } + } + + private func restoreLayoutNode( + _ node: TerminalLayoutSnapshot.LayoutNode, + anchor: GhosttySurfaceView, + tabId: TerminalTabID + ) { + guard case .split(let split) = node else { return } + + // Create the right child by splitting the anchor. Kind dispatch on the + // persisted leaf; a new snapshot kind adds a case. + let rightLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot + switch split.right.firstLeaf { + case .terminal(let leaf): rightLeaf = leaf + } + let rightWorkingDir = rightLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } + let direction: SplitTree.NewDirection = + split.direction == .horizontal ? .right : .down + + guard + let newSurface = createRestorationSplit( + at: anchor, + direction: direction, + ratio: split.ratio, + workingDirectory: rightWorkingDir, + tabId: tabId, + surfaceID: rightLeaf.id, + ) + else { + layoutLogger.warning("Skipping subtree restoration for tab \(tabId.rawValue)") + return + } + + // Recurse into left and right subtrees. + restoreLayoutNode(split.left, anchor: anchor, tabId: tabId) + restoreLayoutNode(split.right, anchor: newSurface, tabId: tabId) + } + + private func createRestorationSplit( + at anchor: GhosttySurfaceView, + direction: SplitTree.NewDirection, + ratio: Double, + workingDirectory: URL?, + tabId: TerminalTabID, + surfaceID: UUID? = nil + ) -> GhosttySurfaceView? { + guard var tree = trees[tabId] else { return nil } + let newSurface = createSurface( + tabId: tabId, + initialInput: nil, + workingDirectoryOverride: workingDirectory, + inheritingFromSurfaceId: anchor.id, + context: GHOSTTY_SURFACE_CONTEXT_SPLIT, + surfaceID: surfaceID, + ) + do { + tree = try tree.inserting(view: newSurface, at: anchor, direction: direction, ratio: ratio) + setTree(tree, for: tabId) + return newSurface + } catch { + layoutLogger.warning("Failed to restore split for tab \(tabId.rawValue): \(error)") + newSurface.closeSurface() + discardSurfaceBookkeeping(for: newSurface.id) + return nil + } + } + + func needsSetupScript() -> Bool { + pendingSetupScript + } + + func enableSetupScriptIfNeeded() { + if pendingSetupScript { + return + } + if tabManager.tabs.isEmpty { + pendingSetupScript = true + } + } + + // Fires when the blocking command finishes. The shell stays alive + // so the user can inspect output. Completion is reported here for + // all exit codes. `handleBlockingScriptChildExited` covers the + // separate case where the shell exits before the command finishes. + private func handleBlockingScriptCommandFinished(tabId: TerminalTabID, exitCode: Int?) { + guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } + blockingScriptLogger.info("\(kind.tabTitle) finished with exit code \(exitCode.map(String.init) ?? "nil")") + completeBlockingScript(kind, tabId: tabId, exitCode: exitCode, reportedTabId: tabId) + } + + // Shell self-exit. A finished command already cleared tracking in + // `handleBlockingScriptCommandFinished`, so this no-ops. Local: user quit + // (exit / Ctrl+D), a cancellation. Remote: the child is ssh, so a failed run. + private func handleBlockingScriptChildExited(tabId: TerminalTabID, exitCode: UInt32) { + guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } + // Remote ssh exit codes are unreliable (login wrapper); force failure so a + // raw 0 can't hit a lifecycle success path, and report no tab (ghostty + // already closed the surface). + guard worktree.host == nil else { + blockingScriptLogger.warning("\(kind.tabTitle) ssh exited before completion (raw exit code \(exitCode))") + completeBlockingScript(kind, tabId: tabId, exitCode: 1, reportedTabId: nil) + return + } + blockingScriptLogger.info( + "\(kind.tabTitle) cancelled (shell exited before command finished, raw exit code \(exitCode))" + ) + completeBlockingScript(kind, tabId: tabId, exitCode: nil, reportedTabId: nil) + } + + // Marks the blocking-script tab as completed and flips every surface in + // it to Ghostty's readonly mode so the user can't keep typing into a + // shell that won't survive app quit. Fires the completion callback + // asynchronously unless a new script of the same kind already started. + private func completeBlockingScript( + _ kind: BlockingScriptKind, + tabId: TerminalTabID, + exitCode: Int?, + reportedTabId: TerminalTabID? + ) { + tabManager.markBlockingScriptCompleted(tabId) + freezeBlockingScriptSurfaces(in: tabId) + emitTaskStatusIfChanged() + + Task { @MainActor [weak self] in + guard let self else { + blockingScriptLogger.debug("\(kind.tabTitle) completion dropped (state deallocated)") + return + } + guard !self.blockingScripts.values.contains(kind) else { + blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") + return + } + self.onBlockingScriptCompleted?(kind, exitCode, reportedTabId) + } + } + + private func freezeBlockingScriptSurfaces(in tabId: TerminalTabID) { + for surfaceID in surfaceIDs(inTab: tabId) { + surfaces[surfaceID]?.enableReadOnly() + } + } + + private func createSurface( + tabId: TerminalTabID, + command: String? = nil, + initialInput: String?, + workingDirectoryOverride: URL? = nil, + inheritingFromSurfaceId: UUID?, + context: ghostty_surface_context_e, + surfaceID: UUID? = nil, + bypassZmx: Bool = false, + replacingExistingSurfaceID: Bool = false, + ) -> GhosttySurfaceView { + let resolvedID: UUID + if let requested = surfaceID { + if surfaces[requested] != nil, !replacingExistingSurfaceID { + terminalStateLogger.warning("Duplicate surface ID \(requested), generating a new one.") + resolvedID = UUID() + } else { + resolvedID = requested + } + } else { + resolvedID = UUID() + } + let surfaceID = resolvedID + terminalStateLogger.info("createSurface: resolved=\(surfaceID)") + let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) + let launch = terminal.resolveLaunch( + surfaceID: surfaceID, + command: command, + initialInput: initialInput, + bypassZmx: bypassZmx, + ) + // Remote worktrees have no local working directory: the surface command is + // an `ssh …` line (see `resolveLaunch`) and the cwd lives on the + // remote, so leave `working_directory` nil and let the remote shell `cd`. + let resolvedWorkingDirectory: URL? = + worktree.host == nil + ? (workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory) + : nil + let view = GhosttySurfaceView( + id: surfaceID, + runtime: runtime, + workingDirectory: resolvedWorkingDirectory, + command: launch.command, + initialInput: launch.initialInput, + environmentVariables: terminal.surfaceEnvironment(tabId: tabId, surfaceID: surfaceID), + commandWrapper: launch.commandWrapper, + // Blocking-script runners (bypassZmx) emit their own OSC 133/7 and must + // not get Ghostty's shell integration injected into the host shell. + disableShellIntegration: bypassZmx, + fontSize: inherited.fontSize ?? rememberedZoomFontSize, + context: context + ) + wireSurfaceCallbacks(view: view, tabId: tabId) + surfaces[view.id] = view + surfaceLaunchMetadata[view.id] = .init(usesZmx: launch.usesZmx, context: context) + surfaceStates[view.id] = SurfaceIndicatorState() + return view + } + + /// Extracted from `createSurface` so the latter stays under swiftlint's + /// cyclomatic-complexity cap. The closures all branch on `[weak self, + /// weak view]` so the count adds up fast. + private func wireSurfaceCallbacks( + view: GhosttySurfaceView, + tabId: TerminalTabID + ) { + wireSurfaceTabCallbacks(view: view, tabId: tabId) + wireSurfaceLifecycleCallbacks(view: view, tabId: tabId) + } + + /// Tab / title / split callbacks. Split from `wireSurfaceLifecycleCallbacks` + /// so each stays under swiftlint's cyclomatic-complexity cap. + private func wireSurfaceTabCallbacks( + view: GhosttySurfaceView, + tabId: TerminalTabID + ) { + view.bridge.onTitleChange = { [weak self, weak view] title in + guard let self, let view else { return } + guard self.isLiveSurface(view) else { return } + if self.focusedSurfaceIdByTab[tabId] == view.id { + self.tabManager.updateTitle(tabId, title: title) + } + } + view.bridge.onPromptTitle = { [weak self, weak view] in + guard let self, let view, self.isLiveSurface(view) else { return } + self.tabManager.beginTabRename(tabId) + } + view.bridge.onSplitAction = { [weak self, weak view] action in + guard let self, let view else { return false } + guard self.isLiveSurface(view) else { return false } + return self.performSplitAction(action, for: view.id) + } + view.bridge.onNewTab = { [weak self, weak view] in + guard let self, let view else { return false } + guard self.isLiveSurface(view) else { return false } + return self.createTab(inheritingFromSurfaceId: view.id) != nil + } + view.bridge.onCloseTab = { [weak self, weak view] _ in + guard let self, let view, self.isLiveSurface(view) else { return false } + self.closeTab(tabId) + return true + } + view.bridge.onGotoTab = { [weak self, weak view] target in + guard let self, let view, self.isLiveSurface(view) else { return false } + return self.handleGotoTabRequest(target) + } + view.bridge.onCommandPaletteToggle = { [weak self, weak view] in + guard let self, let view, self.isLiveSurface(view) else { return false } + self.onCommandPaletteToggle?() + return true + } + } + + /// Progress / exit / notification / focus callbacks. + private func wireSurfaceLifecycleCallbacks( + view: GhosttySurfaceView, + tabId: TerminalTabID + ) { + view.bridge.onProgressReport = { [weak self, weak view] _ in + guard let self, let view, self.isLiveSurface(view) else { return } + self.updateRunningState(for: tabId) + } + view.bridge.onCommandFinished = { [weak self, weak view] exitCode in + guard let self, let view, self.isLiveSurface(view) else { return } + self.handleBlockingScriptCommandFinished(tabId: tabId, exitCode: exitCode) + } + view.bridge.onChildExited = { [weak self, weak view] exitCode in + guard let self, let view, self.isLiveSurface(view) else { return } + self.handleBlockingScriptChildExited(tabId: tabId, exitCode: exitCode) + } + view.bridge.onDesktopNotification = { [weak self, weak view] title, body in + guard let self, let view else { return } + guard self.isLiveSurface(view) else { return } + self.handleAgentOSCNotification(title: title, body: body, surfaceID: view.id) + } + view.bridge.onContextSignal = { [weak self, weak view] _, id, metadata in + guard let self, let view else { return } + guard self.isLiveSurface(view) else { return } + self.handleContextSignal(surfaceID: view.id, id: id, metadata: metadata) + } + view.bridge.onCloseRequest = { [weak self, weak view] _ in + guard let self, let view else { return } + self.handleCloseRequest(for: view) + } + view.onFocusChange = { [weak self, weak view] focused in + guard let self, let view, focused else { return } + guard self.isLiveSurface(view) else { return } + self.recordActiveSurface(view, in: tabId) + self.emitTaskStatusIfChanged() + } + view.bridge.onColorChanged = { [weak self, weak view] in + guard let self, let view, self.isLiveSurface(view) else { return } + // Only the focused surface drives the window tint. + guard self.focusedSurfaceIdByTab[tabId] == view.id else { return } + self.onFocusedSurfaceColorChanged?() + } + view.shouldClaimFocus = { [weak self, weak view] in + guard let self, let view, self.isLiveSurface(view) else { return false } + return self.focusedSurfaceIdByTab[tabId] == view.id + } + } + + // Identity, not key presence: a reattached surface keeps its UUID, so stale closures from the old view must no-op. + private func isLiveSurface(_ view: GhosttySurfaceView) -> Bool { + surfaces[view.id] === view + } + + // The bridge state of the focused surface in the selected tab, if any. Used to + // resolve the window tint from the focused surface's OSC 11 background. + func focusedSurfaceState() -> GhosttySurfaceState? { + guard let tabID = tabManager.selectedTabId, + let surfaceID = focusedSurfaceIdByTab[tabID], + let surface = surfaces[surfaceID] + else { return nil } + return surface.bridge.state + } + + /// Routes an OSC 3008 context signal to the presence or notify handler. + private func handleContextSignal(surfaceID: UUID, id: String, metadata: String) { + // Route by notify INTENT, not by parse success, so a malformed notify logs as + // a notify drop rather than silently falling through to the presence handler. + if AgentPresenceOSC.isNotifyMetadata(metadata) { + handleNotifySignal(surfaceID: surfaceID, id: id, metadata: metadata) + } else { + handlePresenceSignal(surfaceID: surfaceID, id: id, metadata: metadata) + } + } + + /// Verify an OSC 3008 presence signal against the receiving surface's nonce, + /// then synthesize an `AgentHookEvent` and forward it to the manager. Attribution + /// is by the receiving surface, so the wire never carries a surface id that could + /// spoof another worktree's badge; a pid rides along only for local hooks. + private func handlePresenceSignal(surfaceID: UUID, id: String, metadata: String) { + switch Self.presenceEvent( + id: id, + metadata: metadata, + surfaceID: surfaceID, + surfaceExists: surfaces[surfaceID] != nil + ) { + case .success(let event): + onAgentHookEvent?(event) + case .failure(.parseFailed): + // Malformed metadata on a live surface is probe-shaped; warn (mirrors notify). + terminalStateLogger.warning("Dropped malformed OSC presence signal for surface \(surfaceID).") + case .failure(.unknownSurface): + terminalStateLogger.debug("Dropped OSC presence signal for surface \(surfaceID).") + } + } + + /// Typed reasons a presence signal was dropped, so the single call site can pick a + /// log severity per cause (warn for malformed, debug otherwise). + enum PresenceDrop: Error, Equatable { + case unknownSurface + case parseFailed + } + + /// Pure decision for an OSC presence signal: returns an `AgentHookEvent` + /// attributed to the RECEIVING surface when the surface is known and the metadata + /// is well-formed; otherwise a typed `PresenceDrop` so the caller can log per + /// cause. The wire never carries a surface id (so a payload can't spoof another + /// worktree). The parser rejects a non-positive pid before it could reach the + /// liveness sweep; a forged positive pid at worst pins a live-looking badge. + nonisolated static func presenceEvent( + id: String, + metadata: String, + surfaceID: UUID, + surfaceExists: Bool + ) -> Result { + guard surfaceExists else { return .failure(.unknownSurface) } + guard let signal = AgentPresenceOSC.parse(id: id, metadata: metadata) else { + return .failure(.parseFailed) + } + return .success( + AgentHookEvent( + agent: signal.agent, event: signal.eventRawValue, surfaceID: surfaceID, pid: signal.pid)) + } + + /// Parse an OSC 3008 notify signal for the receiving surface, then sanitize and + /// display it. Gated by the rich-notifications setting. + private func handleNotifySignal(surfaceID: UUID, id: String, metadata: String) { + switch Self.notification( + id: id, + metadata: metadata, + surfaceExists: surfaces[surfaceID] != nil + ) { + case .success(let resolved): + // Gate AFTER parse so the setting can't be probed via drop-rate signals. + @Shared(.settingsFile) var settingsFile + guard settingsFile.global.richAgentNotificationsEnabled else { + terminalStateLogger.debug("Dropped OSC notify; rich notifications disabled.") + return + } + // A body present on the wire but decoded empty means a truncation, an + // escape-cut the shed loop couldn't recover, or a non-base64 (probe / forged) + // field: keep it out of silent-failure territory by logging, even though we + // still show the title-only toast. + if resolved.body.isEmpty, resolved.wireBodyByteCount > 0 { + let wireBytes = resolved.wireBodyByteCount + terminalStateLogger.warning( + "OSC notify body present on wire (\(wireBytes) b64 bytes) but decoded empty, dropped: surface \(surfaceID)." + ) + } + appendHookNotification(title: resolved.title, body: resolved.body, surfaceID: surfaceID) + case .failure(.parseFailed): + // parseNotify only fails on a non-notify / empty id (not a truncated body, + // which decodes to an empty field, logged in the success arm above). + terminalStateLogger.warning( + "Dropped malformed OSC notify (metadata bytes: \(metadata.utf8.count)) for surface \(surfaceID).") + case .failure(.unknownSurface), .failure(.empty): + terminalStateLogger.debug("Dropped OSC notify signal for surface \(surfaceID).") + } + } + + /// Typed reasons a notify signal was dropped, so the single call site can pick a + /// log severity per cause (warn for malformed, debug otherwise). + enum NotifyDrop: Error { + case unknownSurface + case parseFailed + case empty + } + + /// A parsed + sanitized notify ready for display, plus the raw wire body byte + /// count so the call site can log a truncated-to-empty body. + struct ResolvedNotification: Equatable { + let title: String + let body: String + let wireBodyByteCount: Int + } + + /// Pure parse decision for an OSC notify signal. Title/body are bounded and + /// stripped of control characters since anything on the terminal can emit one. + /// Title falls back to the agent name; body may be empty. + nonisolated static func notification( + id: String, + metadata: String, + surfaceExists: Bool + ) -> Result { + guard surfaceExists else { return .failure(.unknownSurface) } + guard let notify = AgentPresenceOSC.parseNotify(id: id, metadata: metadata) else { + return .failure(.parseFailed) + } + // Second-line defense behind the emit-side caps (notifyTitleByteBudget / + // notifyBodyByteBudget): these are scalar counts, not bytes, and the wire is + // already bounded, so they only bite on a hand-crafted oversized payload. + let title = sanitizeNotificationText(notify.title ?? notify.agent, max: 200) + let body = sanitizeNotificationText(notify.body ?? "", max: 1000) + guard !(title.isEmpty && body.isEmpty) else { return .failure(.empty) } + return .success(ResolvedNotification(title: title, body: body, wireBodyByteCount: notify.wireBodyByteCount)) + } + + /// Bound length and neutralize control characters in attacker-influenceable + /// notification text. Newline / tab / carriage return collapse to a space; + /// other C0 controls and DEL are dropped (defends against escape-sequence + /// injection into the toast). Length is capped in unicode scalars. + nonisolated static func sanitizeNotificationText(_ text: String, max: Int) -> String { + var scalars = String.UnicodeScalarView() + for scalar in text.unicodeScalars { + if scalars.count >= max { break } + switch scalar.value { + case 0x0A, 0x09, 0x0D: + scalars.append(" ") + case 0x00...0x1F, 0x7F: + continue + default: + scalars.append(scalar) + } + } + return String(scalars).trimmingCharacters(in: .whitespaces) + } + + private struct InheritedSurfaceConfig: Equatable { + let workingDirectory: URL? + let fontSize: Float32? + } + + private func inheritedSurfaceConfig( + fromSurfaceId surfaceID: UUID?, + context: ghostty_surface_context_e + ) -> InheritedSurfaceConfig { + guard let surfaceID, + let view = surfaces[surfaceID], + let sourceSurface = view.surface + else { + return InheritedSurfaceConfig(workingDirectory: nil, fontSize: nil) + } + + let inherited = ghostty_surface_inherited_config(sourceSurface, context) + let fontSize = inherited.font_size == 0 ? nil : inherited.font_size + let workingDirectory = inherited.working_directory.flatMap { ptr -> URL? in + let path = String(cString: ptr) + if path.isEmpty { + return nil + } + return URL(fileURLWithPath: path, isDirectory: true) + } + return InheritedSurfaceConfig(workingDirectory: workingDirectory, fontSize: fontSize) + } + + private static let rememberedZoomFontSizeKey = "terminalRememberedFontSize" + + /// Seed for a sourceless surface, gated on `window-inherit-font-size`. + private var rememberedZoomFontSize: Float32? { + guard runtime.windowInheritsFontSize() else { return nil } + @Shared(.appStorage(Self.rememberedZoomFontSizeKey)) var stored: Double = 0 + return stored > 0 ? Float32(stored) : nil + } + + /// Sample and persist the focused surface's zoom (worktree switch, quit). + func rememberFocusedZoom() { + guard let id = currentFocusedSurfaceId(), let surface = surfaces[id]?.surface else { return } + persistZoomFontSize(ghostty_surface_font_size(surface)) + } + + /// 0 clears a prior zoom, matching Ghostty dropping the override on reset. + private func persistZoomFontSize(_ size: Float32) { + guard runtime.windowInheritsFontSize() else { return } + @Shared(.appStorage(Self.rememberedZoomFontSizeKey)) var stored: Double = 0 + $stored.withLock { $0 = Double(max(size, 0)) } + } + + private func currentFocusedSurfaceId() -> UUID? { + guard let selectedTabId = tabManager.selectedTabId else { return nil } + return focusedSurfaceIdByTab[selectedTabId] + } + + private func updateTabTitle(for tabId: TerminalTabID) { + guard let focusedId = focusedSurfaceIdByTab[tabId], + let surface = surfaces[focusedId], + let title = surface.bridge.state.title + else { return } + tabManager.updateTitle(tabId, title: title) + } + + private func focusSurface(in tabId: TerminalTabID) { + if let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] { + focusSurface(surface, in: tabId) + return + } + let tree = splitTree(for: tabId) + if let leaf = tree.visibleLeaves().first { + focusLeaf(leaf, in: tabId) + } + } + + /// Focuses a split-tree leaf by routing through its content kind. Terminal is + /// the only kind today; a new leaf kind adds a `case` here that the compiler + /// forces. + private func focusLeaf(_ leaf: SurfaceView, in tabId: TerminalTabID) { + switch leaf.content { + case .terminal(let surface): focusSurface(surface, in: tabId) + } + } + + private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { + let previousSurface = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0] } + recordActiveSurface(surface, in: tabId) + guard tabId == tabManager.selectedTabId else { return } + let fromSurface = (previousSurface === surface) ? nil : previousSurface + GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) + } + + // Single choke point for mutating the "active pane" of a tab. Reached both + // from explicit focus paths (programmatic focus, split navigation, zoom) + // and from AppKit responder changes when the user clicks a pane. + private func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { + setFocusedSurface(surface.id, for: tabId) + markNotificationsRead(forSurfaceID: surface.id) + updateTabTitle(for: tabId) + emitFocusChangedIfNeeded(surface.id) + } + + // Single source of truth for the tab's active pane so the overlay renderer + // can't drift across surfaces. Self-corrects when the stored id points at a + // since-closed surface (or is nil while leaves still exist): a tab with any + // visible leaves must report exactly one of them as active, otherwise the + // dim-overlay reads either "no surface selected" (no leaf matches) or "all + // surfaces selected" (no id → guard short-circuits the dim check for every + // leaf). + func activeSurfaceID(for tabId: TerminalTabID) -> UUID? { + if let stored = focusedSurfaceIdByTab[tabId], surfaces[stored] != nil { + return stored + } + return trees[tabId]?.visibleLeaves().first?.id + } + + /// Appends a notification from a custom (hook / OSC 3008) source. Records the + /// time so the agent's own OSC 9 for the same event is deduped, and cancels any + /// OSC 9 currently held for this surface (the expanded one supersedes it). + func appendHookNotification(title: String, body: String, surfaceID: UUID) { + guard surfaces[surfaceID] != nil else { + terminalStateLogger.debug("Dropped hook notification for unknown surface \(surfaceID) in worktree \(worktree.id)") + return + } + lastCustomNotificationAt[surfaceID] = clock.now + if let superseded = pendingAgentOSCNotifications.removeValue(forKey: surfaceID) { + superseded.cancel() + terminalStateLogger.debug( + "Dropped held agent OSC 9 for surface \(surfaceID) in worktree \(worktree.id): superseded by hook notification" + ) + } + appendNotification(title: title, body: body, surfaceID: surfaceID) + } + + /// The agent's own OSC 9 desktop notification, a summary of the expanded custom + /// notification we ship. Deduped: dropped if a custom notification just + /// committed for this surface (hook-first); otherwise held briefly and dropped + /// if a custom one supersedes it during the hold (OSC-9-first), else shown. + private func handleAgentOSCNotification(title: String, body: String, surfaceID: UUID) { + if let last = lastCustomNotificationAt[surfaceID], + Self.elapsed(from: last, to: clock.now) <= .seconds(Self.oscSuppressionAfterCustom) + { + terminalStateLogger.debug( + "Dropped agent OSC 9 for surface \(surfaceID) in \(worktree.id): custom notification within dedupe window" + ) + return + } + let clock = clock + pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() + pendingAgentOSCNotifications[surfaceID] = Task { [weak self] in + do { + try await clock.sleep(for: .seconds(Self.oscHoldWindow)) + } catch is CancellationError { + return + } catch { + terminalStateLogger.error("OSC 9 hold sleep failed: \(error)") + return + } + guard !Task.isCancelled, let self else { return } + self.pendingAgentOSCNotifications.removeValue(forKey: surfaceID) + guard self.surfaces[surfaceID] != nil else { return } + self.appendNotification(title: title, body: body, surfaceID: surfaceID) + } + } + + private func appendNotification(title: String, body: String, surfaceID: UUID) { + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) + guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } + let isViewed = isViewedSurface(surfaceID) + if notificationsEnabled { + notifications.insert( + WorktreeTerminalNotification( + surfaceID: surfaceID, + title: trimmedTitle, + body: trimmedBody, + createdAt: now, + isRead: isViewed + ), + at: 0 + ) + refreshSurfaceUnseenFlag(surfaceID) + if let tabId = tabID(containing: surfaceID) { + emitTabProjection(for: tabId) + } + emitNotificationStateChanged() + } + onNotificationReceived?(surfaceID, trimmedTitle, trimmedBody, isViewed) + } + + /// Detaches one surface from the local bookkeeping. The zmx session is NOT + /// killed here; callers route the kill through `killZmxSessions(forSurfaceIDs:)` + /// so a single multi-pane close emits one `count=N` analytics event + one + /// `withTaskGroup` instead of N events and N detached Tasks. + /// Also cancels any held agent OSC 9 and forgets the last-custom-notification + /// instant so a future surface ID can't reuse stale dedupe state. + private func discardSurfaceBookkeeping(for surfaceID: UUID) { + pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() + lastCustomNotificationAt.removeValue(forKey: surfaceID) + surfaces.removeValue(forKey: surfaceID) + surfaceLaunchMetadata.removeValue(forKey: surfaceID) + pendingExplicitSurfaceCloseIDs.remove(surfaceID) + surfaceStates.removeValue(forKey: surfaceID) + } + + private func cleanupSurfaceState(for surfaceID: UUID) { + discardSurfaceBookkeeping(for: surfaceID) + onSurfacesClosed?([surfaceID]) + } + + private func removeTree(for tabId: TerminalTabID) { + guard let tree = trees.removeValue(forKey: tabId) else { return } + surfaceGenerationByTab.removeValue(forKey: tabId) + let leafIDs = tree.leaves().map(\.id) + for leaf in tree.leaves() { + switch leaf.content { + case .terminal(let surface): + surface.closeSurface() + cleanupSurfaceState(for: surface.id) + } + } + terminal.killZmxSessions(forSurfaceIDs: leafIDs, includeRemote: true) + focusedSurfaceIdByTab.removeValue(forKey: tabId) + if lastTabProjections.removeValue(forKey: tabId) != nil { + onTabRemoved?(tabId) + } + } + + func tabID(containing surfaceID: UUID) -> TerminalTabID? { + for (tabId, tree) in trees where tree.find(id: surfaceID) != nil { + return tabId + } + return nil + } + + private func isFocusedSurface(_ surfaceID: UUID) -> Bool { + guard let selectedTabId = tabManager.selectedTabId else { + return false + } + return focusedSurfaceIdByTab[selectedTabId] == surfaceID + } + + private func isViewedSurface(_ surfaceID: UUID) -> Bool { + isSelected() && isFocusedSurface(surfaceID) && isVisibleSurface(surfaceID) + && lastWindowIsKey == true && lastWindowIsVisible == true + } + + // A split-zoomed tab hides every pane outside the zoomed subtree, so a focused + // pane can still be off screen; gate on the zoom-aware visible leaves. + private func isVisibleSurface(_ surfaceID: UUID) -> Bool { + guard let selectedTabId = tabManager.selectedTabId else { return false } + return trees[selectedTabId]?.visibleLeaves().contains { $0.id == surfaceID } == true + } + + /// True for a blocking-script tab whose script has already finished. + func isBlockingScriptCompleted(_ tabId: TerminalTabID) -> Bool { + tabManager.tabs.first(where: { $0.id == tabId })?.isBlockingScriptCompleted == true + } + + private func updateRunningState(for tabId: TerminalTabID) { + guard trees[tabId] != nil else { return } + // Frozen tabs stay sticky: the bridge's stale watch re-fires + // `onProgressReport(REMOVE)` after `command_finished` and would otherwise + // resurrect the dirty shimmer on a tab the user reads as done. + let isFrozen = isBlockingScriptCompleted(tabId) + tabManager.updateDirty(tabId, isDirty: isFrozen ? false : isTabBusy(tabId)) + emitTabProgressDisplay(for: tabId) + emitTaskStatusIfChanged() + } + + /// Compute the per-tab stripe progress payload off `trees[tabId]`'s surfaces. + /// Selected tab → focused-surface state; unselected tab → worst-of-all + /// (ERROR > PAUSE > determinate > indeterminate > none). + private func computeTabProgressDisplay(for tabId: TerminalTabID) -> TerminalTabProgressDisplay? { + guard let tree = trees[tabId] else { return nil } + let leaves = tree.leaves() + if tabManager.selectedTabId == tabId, + let focusedID = focusedSurfaceIdByTab[tabId], + let focused = leaves.first(where: { $0.id == focusedID }) + { + switch focused.content { + case .terminal(let surface): + return TerminalTabProgressDisplay.make( + progressState: surface.bridge.state.progressState, + progressValue: surface.bridge.state.progressValue + ) + } + } + var worst: TerminalTabProgressDisplay? + for leaf in leaves { + switch leaf.content { + case .terminal(let surface): + guard + let candidate = TerminalTabProgressDisplay.make( + progressState: surface.bridge.state.progressState, + progressValue: surface.bridge.state.progressValue + ) + else { continue } + if worst == nil || candidate.severity > worst!.severity { + worst = candidate + } + } + } + return worst + } + + /// Recompute and emit the tab's progress display when it differs from the + /// cached value. Idempotent so OSC-9 ticks that don't move the stripe state + /// don't fire the callback. + private func emitTabProgressDisplay(for tabId: TerminalTabID) { + let newDisplay = computeTabProgressDisplay(for: tabId) + if lastTabProgressDisplays[tabId] != newDisplay { + lastTabProgressDisplays[tabId] = newDisplay + onTabProgressDisplayChanged?(tabId, newDisplay) + } + } + + private func emitTaskStatusIfChanged() { + let newStatus = taskStatus + if newStatus != lastReportedTaskStatus { + lastReportedTaskStatus = newStatus + onTaskStatusChanged?(newStatus) + } + } + + private func emitFocusChangedIfNeeded(_ surfaceID: UUID) { + guard surfaceID != lastEmittedFocusSurfaceId else { return } + lastEmittedFocusSurfaceId = surfaceID + onFocusChanged?(surfaceID) + } + + /// `currentProjection()` already includes the full list and per-item `isRead`, + /// so the sidebar/popover must re-sync on every mutation, not just when + /// `hasUnseenNotification` flips. Gating here broke dismiss / mark-read of + /// already-read notifications (#385). Downstream emits self-dedupe, so keep + /// this ungated. + private func emitNotificationStateChanged() { + onNotificationIndicatorChanged?() + } + + private func syncFocusIfNeeded() { + guard lastWindowIsKey != nil, lastWindowIsVisible != nil else { return } + applySurfaceActivity() + } + + private func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { + setTree(tree, for: tabId) + syncFocusIfNeeded() + } + + /// Single mutation point for `trees[tabId]`. Recomputes and emits the per-tab + /// projection so `TerminalTabFeature.State` mirrors `trees[tabId]`'s leaves + /// + the tab's unread count + focus without observing worktree-wide state. + private func setTree(_ tree: SplitTree, for tabId: TerminalTabID) { + trees[tabId] = tree + // Zoom transitions flip the hide-single-tab-bar gate. + updateShouldHideTabBar() + emitTabProjection(for: tabId) + } + + /// Single mutation point for `focusedSurfaceIdByTab[tabId]`. Mirrors into the + /// per-tab projection so the stripe-progress leaf observes the focus change + /// per-tab instead of through the worktree-wide dictionary. + private func setFocusedSurface(_ surfaceID: UUID?, for tabId: TerminalTabID) { + if let surfaceID { + focusedSurfaceIdByTab[tabId] = surfaceID + } else { + focusedSurfaceIdByTab.removeValue(forKey: tabId) + } + emitTabProjection(for: tabId) + } + + /// Recompute the per-tab projection and emit `onTabProjectionChanged` when + /// the value differs from the cached one. Idempotent: a no-op rebuild + /// (e.g. a notification arrived on a surface that's already counted) does + /// not fire the callback. + private func emitTabProjection(for tabId: TerminalTabID) { + guard let tree = trees[tabId] else { + surfaceGenerationByTab.removeValue(forKey: tabId) + if lastTabProjections.removeValue(forKey: tabId) != nil { + onTabRemoved?(tabId) + } + return + } + let surfaceIDs = tree.leaves().map(\.id) + let surfaceIDSet = Set(surfaceIDs) + let unseenCount = notifications.reduce(into: 0) { partial, notification in + if !notification.isRead, surfaceIDSet.contains(notification.surfaceID) { + partial += 1 + } + } + let projection = WorktreeTabProjection( + tabID: tabId, + surfaceIDs: surfaceIDs, + activeSurfaceID: focusedSurfaceIdByTab[tabId], + unseenNotificationCount: unseenCount, + isSplitZoomed: tree.zoomed != nil, + surfaceGeneration: surfaceGenerationByTab[tabId, default: 0], + ) + guard lastTabProjections[tabId] != projection else { return } + lastTabProjections[tabId] = projection + onTabProjectionChanged?(projection) + } + + /// Recompute every tab's projection. Used after notification-list mutations + /// that may span multiple tabs (mark-all-read, dismiss-all). + private func emitAllTabProjections() { + for tabId in trees.keys { + emitTabProjection(for: tabId) + } + } + + /// Snapshot all current tab projections. Manager replays this on every fresh + /// event-stream subscriber so `terminalTabs[id:]` reconstructs without + /// waiting for the next per-tab mutation. + func currentTabProjections() -> [WorktreeTabProjection] { + Array(lastTabProjections.values) + } + + /// Snapshot all current per-tab stripe-progress displays. Replayed alongside + /// `currentTabProjections()` so the stripe paints the right state on the + /// first frame after re-subscribe. + func currentTabProgressDisplays() -> [TerminalTabID: TerminalTabProgressDisplay?] { + lastTabProgressDisplays + } + + private func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool { + switch state { + case .some(GHOSTTY_PROGRESS_STATE_SET), + .some(GHOSTTY_PROGRESS_STATE_INDETERMINATE), + .some(GHOSTTY_PROGRESS_STATE_PAUSE), + .some(GHOSTTY_PROGRESS_STATE_ERROR): + return true + default: + return false + } + } + + private func mapSplitDirection(_ direction: GhosttySplitAction.NewDirection) + -> SplitTree.NewDirection + { + switch direction { + case .left: + return .left + case .right: + return .right + case .top: + return .top + case .down: + return .down + } + } + + private func mapFocusDirection(_ direction: GhosttySplitAction.FocusDirection) + -> SplitTree.FocusDirection + { + switch direction { + case .previous: + return .previous + case .next: + return .next + case .left: + return .spatial(.left) + case .right: + return .spatial(.right) + case .top: + return .spatial(.top) + case .down: + return .spatial(.down) + } + } + + private func mapResizeDirection(_ direction: GhosttySplitAction.ResizeDirection) + -> SplitTree.SpatialDirection + { + switch direction { + case .left: + return .left + case .right: + return .right + case .top: + return .top + case .down: + return .down + } + } + + private func handleCloseRequest(for view: GhosttySurfaceView) { + guard surfaces[view.id] === view else { return } + let isExplicitClose = pendingExplicitSurfaceCloseIDs.remove(view.id) != nil + if shouldHandleAsUnexpectedZmxClose( + surfaceID: view.id, + isExplicitClose: isExplicitClose + ) { + handleUnexpectedZmxClose(for: view) + return + } + // The host-side session dies only on explicit close: a non-explicit exit + // (e.g. a clean remote exit with the session already gone, a deliberate + // host-side detach, or a reconnect abort) spares it. + closeSurfaceAndUpdateTabs(view, killZmxSession: true, includeRemoteSession: isExplicitClose) + } + + private func shouldHandleAsUnexpectedZmxClose( + surfaceID: UUID, + isExplicitClose: Bool + ) -> Bool { + guard !isExplicitClose else { return false } + return surfaceLaunchMetadata[surfaceID]?.usesZmx == true + } + + private func handleUnexpectedZmxClose(for view: GhosttySurfaceView) { + let surfaceID = view.id + let sessionID = ZmxSessionID.make(surfaceID: surfaceID) + let client = zmxClient + Task { @MainActor [weak self, weak view] in + let sessions = await client.listSessionsWithClients() + guard let self, let view, self.surfaces[surfaceID] === view else { return } + guard let sessions else { + terminalStateLogger.info( + "Closing unexpectedly exited zmx surface \(surfaceID) without killing session: probe failed." + ) + self.closeSurfaceAndUpdateTabs(view, killZmxSession: false) + return + } + guard let session = sessions.first(where: { $0.name == sessionID }) else { + self.closeSurfaceAndUpdateTabs(view, killZmxSession: true) + return + } + // Reattach only an idle session we positively own (0 clients). A session + // with another attached client (clients > 0) or an unknown count (nil) must + // never be destroyed, matching the orphan reaper's spare-on-in-use rule. + guard let clients = session.clients, clients == 0 else { + self.closeSurfaceAndUpdateTabs(view, killZmxSession: false) + return + } + if !self.replaceUnexpectedZmxSurface(view) { + self.closeSurfaceAndUpdateTabs(view, killZmxSession: false) + } + } + } + + @discardableResult + private func replaceUnexpectedZmxSurface(_ view: GhosttySurfaceView) -> Bool { + guard let metadata = surfaceLaunchMetadata[view.id], metadata.usesZmx else { return false } + guard zmxClient.executableURL() != nil else { + terminalStateLogger.info( + "Cannot replace unexpectedly exited zmx surface \(view.id): zmx executable unavailable." + ) + return false + } + guard let tabId = tabID(containing: view.id), let tree = trees[tabId], let node = tree.find(id: view.id) else { + return false + } + let previousState = surfaceStates[view.id] + let replacement = createSurface( + tabId: tabId, + initialInput: nil, + inheritingFromSurfaceId: view.id, + context: metadata.context, + surfaceID: view.id, + bypassZmx: false, + replacingExistingSurfaceID: true, + ) + if let previousState { + surfaceStates[view.id] = previousState + } + surfaceLaunchMetadata[view.id] = metadata + do { + let newTree = try tree.replacing(node: node, with: .leaf(view: replacement)) + view.closeSurface() + bumpSurfaceGeneration(for: tabId) + updateTree(newTree, for: tabId) + updateRunningState(for: tabId) + if focusedSurfaceIdByTab[tabId] == view.id { + focusSurface(replacement, in: tabId) + } + terminalStateLogger.info("Reattached unexpectedly exited zmx surface \(view.id).") + return true + } catch { + terminalStateLogger.warning("Failed to replace unexpectedly exited zmx surface \(view.id): \(error).") + replacement.closeSurface() + discardSurfaceBookkeeping(for: replacement.id) + surfaces[view.id] = view + if let previousState { + surfaceStates[view.id] = previousState + } + surfaceLaunchMetadata[view.id] = metadata + return false + } + } + + private func bumpSurfaceGeneration(for tabId: TerminalTabID) { + surfaceGenerationByTab[tabId, default: 0] += 1 + } + + private func closeSurfaceAndUpdateTabs( + _ view: GhosttySurfaceView, + killZmxSession: Bool, + includeRemoteSession: Bool = false + ) { + guard let tabId = tabID(containing: view.id), let tree = trees[tabId] else { + view.closeSurface() + cleanupSurfaceState(for: view.id) + if killZmxSession { + terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) + } + return + } + guard let node = tree.find(id: view.id) else { + view.closeSurface() + cleanupSurfaceState(for: view.id) + if killZmxSession { + terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) + } + return + } + let nextSurface = + focusedSurfaceIdByTab[tabId] == view.id + ? tree.focusTargetAfterClosing(node) + : nil + let newTree = tree.removing(node) + view.closeSurface() + cleanupSurfaceState(for: view.id) + if killZmxSession { + terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) + } + if newTree.isEmpty { + trees.removeValue(forKey: tabId) + focusedSurfaceIdByTab.removeValue(forKey: tabId) + terminal.cleanupBlockingScriptLaunchDirectory(for: tabId) + tabManager.closeTab(tabId) + updateShouldHideTabBar() + if let kind = blockingScripts.removeValue(forKey: tabId) { + lastBlockingScriptTabByKind.removeValue(forKey: kind) + + onBlockingScriptCompleted?(kind, nil, nil) + } else { + for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { + lastBlockingScriptTabByKind.removeValue(forKey: kind) + } + } + emitTaskStatusIfChanged() + // Closing the last surface via `close_surface` removes the tab here but + // skips the `closeTab` projection path; emit one so `onTabRemoved` fires + // and the layout persistence sink observes the tab going away. + emitTabProjection(for: tabId) + return + } + updateTree(newTree, for: tabId) + updateRunningState(for: tabId) + if focusedSurfaceIdByTab[tabId] == view.id { + if let nextSurface { + focusLeaf(nextSurface, in: tabId) + } else { + focusedSurfaceIdByTab.removeValue(forKey: tabId) + } + } + // Invariant: a tab with visible leaves must have a live, focused surface so + // AppKit's firstResponder lands on something the user can type into. The + // transfer above only fires when the closed surface was the recorded + // focused one; re-check afterwards and push focus to the first visible + // leaf when the recorded id still doesn't resolve to a live surface. + if focusedSurfaceIdByTab[tabId].flatMap({ surfaces[$0] }) == nil, + let fallback = newTree.visibleLeaves().first + { + focusLeaf(fallback, in: tabId) + } + } + + // Selects the 1-based Nth tab, clamped to the last tab, matching Ghostty's `goto_tab:N`. + func selectTabAtIndex(_ index: Int) { + let tabs = tabManager.tabs + guard index >= 1, !tabs.isEmpty else { return } + selectTab(tabs[min(index - 1, tabs.count - 1)].id) + } + + private func handleGotoTabRequest(_ target: ghostty_action_goto_tab_e) -> Bool { + let tabs = tabManager.tabs + guard !tabs.isEmpty else { return false } + let raw = Int(target.rawValue) + let selectedIndex = tabManager.selectedTabId.flatMap { selected in + tabs.firstIndex { $0.id == selected } + } + let targetIndex: Int + if raw <= 0 { + switch raw { + case Int(GHOSTTY_GOTO_TAB_PREVIOUS.rawValue): + let current = selectedIndex ?? 0 + targetIndex = (current - 1 + tabs.count) % tabs.count + case Int(GHOSTTY_GOTO_TAB_NEXT.rawValue): + let current = selectedIndex ?? 0 + targetIndex = (current + 1) % tabs.count + case Int(GHOSTTY_GOTO_TAB_LAST.rawValue): + targetIndex = tabs.count - 1 + default: + return false + } + } else { + targetIndex = min(raw - 1, tabs.count - 1) + } + selectTab(tabs[targetIndex].id) + return true + } + + private func mapDropZone(_ zone: TerminalSplitTreeView.DropZone) + -> SplitTree.NewDirection + { + switch zone { + case .top: + return .top + case .bottom: + return .down + case .left: + return .left + case .right: + return .right + } + } + + private func nextTabIndex() -> Int { + let prefix = "\(worktree.name) " + var maxIndex = 0 + for tab in tabManager.tabs { + guard tab.title.hasPrefix(prefix) else { continue } + let suffix = tab.title.dropFirst(prefix.count) + guard let value = Int(suffix) else { continue } + maxIndex = max(maxIndex, value) + } + return maxIndex + 1 + } + + #if DEBUG + /// Test-only seam for bulk-assigning the notifications log. Fans + /// `emitAllTabProjections()` so `lastTabProjections` stays in sync with + /// the raw log; production code must go through the per-event helpers + /// (`appendHookNotification`, `markNotificationsRead`, etc.) which already + /// emit. Gated `#if DEBUG` so release builds genuinely can't reach the + /// projection-bypass path. + func setNotificationsForTesting(_ list: [WorktreeTerminalNotification]) { + notifications = list + clearAllSurfaceUnseenFlags() + for surfaceID in Set(list.map(\.surfaceID)) { + refreshSurfaceUnseenFlag(surfaceID) + } + emitAllTabProjections() + } + + /// Test-only seam for installing a synthetic `SurfaceIndicatorState` without + /// minting a real Ghostty surface. Production writes are gated to + /// `createSurface` / `cleanupSurfaceState`. + func installSurfaceStateForTesting(_ state: SurfaceIndicatorState, forSurfaceID surfaceID: UUID) { + surfaceStates[surfaceID] = state + } + + /// Test-only read of the tab's active pane id. + func focusedSurfaceIDForTesting(in tabId: TerminalTabID) -> UUID? { + focusedSurfaceIdByTab[tabId] + } + + #endif } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift deleted file mode 100644 index f133ac886..000000000 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ /dev/null @@ -1,2599 +0,0 @@ -import AppKit -import CoreGraphics -import Dependencies -import Foundation -import GhosttyKit -import IdentifiedCollections -import Observation -import Sharing -import SupacodeSettingsShared - -private let blockingScriptLogger = SupaLogger("BlockingScript") -private let layoutLogger = SupaLogger("Layout") -private let terminalStateLogger = SupaLogger("Terminal") - -/// Per-tab projection emitted by `WorktreeTerminalState` whenever a tab's -/// surfaces, focus, unread count, or progress display drifts. The parent -/// reducer applies this to the matching `TerminalTabFeature.State` so the -/// tab-bar leaf observes a per-tab store instead of worktree-wide state. -struct WorktreeTabProjection: Equatable, Sendable { - let tabID: TerminalTabID - let surfaceIDs: [UUID] - let activeSurfaceID: UUID? - let unseenNotificationCount: Int - let isSplitZoomed: Bool - /// Per-tab repaint epoch, bumped on same-UUID surface replacement so the view rebuilds. - let surfaceGeneration: Int - - init( - tabID: TerminalTabID, - surfaceIDs: [UUID], - activeSurfaceID: UUID?, - unseenNotificationCount: Int, - isSplitZoomed: Bool = false, - surfaceGeneration: Int = 0, - ) { - self.tabID = tabID - self.surfaceIDs = surfaceIDs - self.activeSurfaceID = activeSurfaceID - self.unseenNotificationCount = unseenNotificationCount - self.isSplitZoomed = isSplitZoomed - self.surfaceGeneration = surfaceGeneration - } -} - -@MainActor -@Observable -final class WorktreeTerminalState { - struct SurfaceActivity: Equatable { - let isVisible: Bool - let isFocused: Bool - } - - let tabManager: TerminalTabManager - /// Terminal-kind store: Ghostty view map, zmx/launch bookkeeping, blocking - /// scripts, setup-script flag, env injection, agent-OSC dedupe. The private - /// accessors below keep the generic-core method bodies unchanged while the - /// state itself lives per-kind; a future surface kind adds a sibling store. - @ObservationIgnored let terminal: TerminalSurfaceStore - private let runtime: GhosttyRuntime - @ObservationIgnored private let splitPreserveZoomOnNavigation: () -> Bool - private let worktree: Worktree - @ObservationIgnored - @SharedReader private var repositorySettings: RepositorySettings - // Observed: any mutation re-renders `WorktreeTerminalTabsView`. Mutate only - // from user-initiated structural changes; per-surface churn must stay on - // `surfaceStates` / `WorktreeTabProjection` to keep agent storms cold. - private var trees: [TerminalTabID: SplitTree] = [:] - /// Owns pane-rearrange drag state for this worktree's terminal area. The view - /// layer reads `isDragging` to mount drop catchers; `onDrop` (wired in `init`) - /// routes a landed drop back into `performSplitOperation`. - @ObservationIgnored let dragCoordinator = SurfaceDragCoordinator() - @ObservationIgnored private var surfaceGenerationByTab: [TerminalTabID: Int] = [:] - @ObservationIgnored private var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] - /// Per-tab projection cache. `WorktreeTerminalState` recomputes from `trees` - /// / `notifications` / `focusedSurfaceIdByTab`, compares to the cached value, - /// and fires `onTabProjectionChanged` only on diff. The manager forwards the - /// projection upstream so `TerminalTabFeature.State` mirrors it. - @ObservationIgnored private var lastTabProjections: [TerminalTabID: WorktreeTabProjection] = [:] - /// Per-tab progress-display cache. Tracks the focused-surface or worst-of - /// aggregate so `onTabProgressDisplayChanged` only fires on diff. - @ObservationIgnored private var lastTabProgressDisplays: [TerminalTabID: TerminalTabProgressDisplay?] = [:] - private(set) var shouldHideTabBar = false - /// Coalesces the per-mutation running-scripts emit into one next-tick emit so - /// mid-operation states (e.g. the supersede clear-then-record in - /// `runBlockingScript`) never reach TCA. - @ObservationIgnored private var pendingRunningScriptsProjectionEmit = false - /// Sticky after first attempt so a reselect after `closeAllTabs` doesn't auto-recreate. - /// Intentionally never reset; resetting would re-arm the bug. - @ObservationIgnored private(set) var hasAttemptedInitialTab = false - @ObservationIgnored var pendingLayoutSnapshot: TerminalLayoutSnapshot? - private var lastReportedTaskStatus: WorktreeTaskStatus? - private var lastEmittedFocusSurfaceId: UUID? - private var lastWindowIsKey: Bool? - private var lastWindowIsVisible: Bool? - /// Raw notification log. `@ObservationIgnored` so per-tab notification ticks - /// flow through `TerminalTabState.unseenNotificationCount` projections instead - /// of invalidating every leaf in the worktree. - @ObservationIgnored private(set) var notifications: [WorktreeTerminalNotification] = [] - /// Per-surface Supacode observables. `@ObservationIgnored` so dict churn - /// doesn't invalidate every leaf; the per-instance `hasUnseenNotification` is - /// the observed signal. - @ObservationIgnored private(set) var surfaceStates: [UUID: WorktreeSurfaceState] = [:] - var notificationsEnabled = true - @ObservationIgnored @Dependency(\.date.now) private var now - @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient - @ObservationIgnored @Dependency(\.analyticsClient) private var analyticsClient - @ObservationIgnored @Dependency(\.continuousClock) private var clock - /// How long after a custom notification the agent's own OSC 9 is suppressed. - /// Split from `oscHoldWindow` so tuning the suppression side cannot silently - /// change the hold side. - private static let oscSuppressionAfterCustom: TimeInterval = 0.5 - /// How long the agent's own OSC 9 is held before firing, waiting for a custom - /// notification to supersede it. Covers the socket-vs-inline-stream arrival skew. - private static let oscHoldWindow: TimeInterval = 0.5 - /// Monotonic gap between two instants from the same clock. Opens the existentials - /// so the suppression window can compare instants of the type-erased clock. - private static func elapsed( - from start: any InstantProtocol, - to end: any InstantProtocol - ) -> Duration { - func gap(_ start: I, _ end: any InstantProtocol) -> Duration - where I.Duration == Duration { - guard let end = end as? I else { - // Fail OPEN: a type mismatch must not pin the dedupe window true forever. - assertionFailure("clock instant type mismatch") - return .seconds(Self.oscSuppressionAfterCustom + 1) - } - return start.duration(to: end) - } - return gap(start, end) - } - #if DEBUG - var debugCustomNotificationTimestampCount: Int { lastCustomNotificationAt.count } - var debugPendingOSCCount: Int { pendingAgentOSCNotifications.count } - #endif - // MARK: - Terminal-kind state accessors (storage lives in `terminal`). - - var socketPath: String? { - get { terminal.socketPath } - set { terminal.socketPath = newValue } - } - private var surfaces: [UUID: GhosttySurfaceView] { - get { terminal.surfaces } - set { terminal.surfaces = newValue } - } - private var surfaceLaunchMetadata: [UUID: TerminalSurfaceStore.SurfaceLaunchMetadata] { - get { terminal.surfaceLaunchMetadata } - set { terminal.surfaceLaunchMetadata = newValue } - } - private var pendingExplicitSurfaceCloseIDs: Set { - get { terminal.pendingExplicitSurfaceCloseIDs } - set { terminal.pendingExplicitSurfaceCloseIDs = newValue } - } - // Every mutation schedules a coalesced row-projection emit so the TCA - // mirror of running scripts reconciles from this single source of truth - // (#573). All writes flow through this accessor; the store never mutates - // its copy directly. - private var blockingScripts: [TerminalTabID: BlockingScriptKind] { - get { terminal.blockingScripts } - set { - terminal.blockingScripts = newValue - scheduleRunningScriptsProjectionEmit() - } - } - private var lastBlockingScriptTabByKind: [BlockingScriptKind: TerminalTabID] { - get { terminal.lastBlockingScriptTabByKind } - set { terminal.lastBlockingScriptTabByKind = newValue } - } - private var pendingSetupScript: Bool { - get { terminal.pendingSetupScript } - set { terminal.pendingSetupScript = newValue } - } - private var lastCustomNotificationAt: [UUID: any InstantProtocol] { - get { terminal.lastCustomNotificationAt } - set { terminal.lastCustomNotificationAt = newValue } - } - private var pendingAgentOSCNotifications: [UUID: Task] { - get { terminal.pendingAgentOSCNotifications } - set { terminal.pendingAgentOSCNotifications = newValue } - } - - var hasUnseenNotification: Bool { - notifications.contains { !$0.isRead } - } - - func hasUnseenNotification(forSurfaceID surfaceID: UUID) -> Bool { - notifications.contains { !$0.isRead && $0.surfaceID == surfaceID } - } - - func hasUnseenNotification(forTabID tabID: TerminalTabID) -> Bool { - guard let tree = trees[tabID] else { return false } - let surfaceIDs = Set(tree.leaves().map(\.id)) - return notifications.contains { !$0.isRead && surfaceIDs.contains($0.surfaceID) } - } - - /// Returns the most recent unread notification in this worktree, or nil. - func latestUnreadNotification() -> WorktreeTerminalNotification? { - unreadNotifications().first - } - - /// Returns all unread notifications in this worktree sorted newest first. - func unreadNotifications() -> [WorktreeTerminalNotification] { - notifications.filter { !$0.isRead }.sorted { $0.createdAt > $1.createdAt } - } - - var isSelected: () -> Bool = { false } - var onNotificationReceived: ((UUID, String, String, Bool) -> Void)? - var onNotificationIndicatorChanged: (() -> Void)? - var onTabCreated: (() -> Void)? - var onTabClosed: (() -> Void)? - /// Fires when the user renames a tab. Manager forwards to the layout-persist - /// sink so a custom title survives relaunch without waiting for quit. - var onTabRenamed: (() -> Void)? - var onFocusChanged: ((UUID) -> Void)? - // Fired when the currently focused surface's background color changes (OSC 11). - var onFocusedSurfaceColorChanged: (() -> Void)? - var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? - var onBlockingScriptCompleted: ((BlockingScriptKind, Int?, TerminalTabID?) -> Void)? - /// Fires (coalesced, next tick) on any `blockingScripts` mutation; the - /// manager re-emits the Equatable-diffed row projection so TCA reconciles - /// to terminal truth. - var onRunningScriptsChanged: (() -> Void)? - var onCommandPaletteToggle: (() -> Void)? - var onSetupScriptConsumed: (() -> Void)? - /// Forwarded to the manager so it can emit a `surfacesClosed` event into TCA. - var onSurfacesClosed: ((Set) -> Void)? - /// Forwarded to the manager's `dispatchHookEvent` so an OSC-sourced presence - /// event joins the same funnel as the socket path (idle-debounce, badge). - var onAgentHookEvent: ((AgentHookEvent) -> Void)? - /// Fires when a tab's per-tab projection (surfaces / focus / unseen count) - /// drifts. Manager forwards into `TerminalTabFeature.State` via - /// `tabProjectionChanged` so the leaf observes a per-tab store. - var onTabProjectionChanged: ((WorktreeTabProjection) -> Void)? - /// Fires when a tab is fully removed (closeTab, closeAll). Manager forwards - /// so the parent reducer drops the corresponding `TerminalTabFeature.State`. - var onTabRemoved: ((TerminalTabID) -> Void)? - /// Fires when a tab's stripe-progress display drifts. Computed off the - /// active surface (selected tab) or worst-of-all (unselected tabs) so the - /// stripe stays in lock-step with focus and OSC-9 progress mutations. - var onTabProgressDisplayChanged: ((TerminalTabID, TerminalTabProgressDisplay?) -> Void)? - - init( - runtime: GhosttyRuntime, - worktree: Worktree, - runSetupScript: Bool = false, - splitPreserveZoomOnNavigation: (() -> Bool)? = nil - ) { - self.runtime = runtime - self.splitPreserveZoomOnNavigation = splitPreserveZoomOnNavigation ?? { runtime.splitPreserveZoomOnNavigation() } - self.worktree = worktree - self.terminal = TerminalSurfaceStore(worktree: worktree, pendingSetupScript: runSetupScript) - self.tabManager = TerminalTabManager() - _repositorySettings = SharedReader( - wrappedValue: RepositorySettings.default, - .repositorySettings(worktree.repositoryRootURL, host: worktree.host) - ) - // Pre-hide the tab bar before the first tab is created to - // avoid a visible flash. updateShouldHideTabBar() handles - // the steady state once tabs exist. - @Shared(.settingsFile) var settingsFile - self.shouldHideTabBar = settingsFile.global.hideSingleTabBar - dragCoordinator.onDrop = { [weak self] payloadID, destinationID, zone in - guard let self, let tabId = self.tabID(containing: destinationID) else { return } - self.performSplitOperation( - .drop(payloadId: payloadID, destinationId: destinationID, zone: zone), in: tabId) - } - } - - var taskStatus: WorktreeTaskStatus { - trees.keys.contains(where: { isTabBusy($0) }) ? .running : .idle - } - - private func isTabBusy(_ tabId: TerminalTabID) -> Bool { - guard let tree = trees[tabId] else { return false } - return tree.leaves().contains { leaf in - switch leaf.content { - case .terminal(let surface): isRunningProgressState(surface.bridge.state.progressState) - } - } - } - - /// Per-row projection consumed by `SidebarItemFeature.terminalProjectionChanged`. - /// `isProgressBusy` reflects Ghostty progress state only; AppFeature merges - /// agent activity downstream of this event. - func currentProjection() -> WorktreeRowProjection { - WorktreeRowProjection( - surfaceIDs: allSurfaceIDs, - isProgressBusy: taskStatus == .running, - hasUnseenNotifications: hasUnseenNotification, - notifications: IdentifiedArray(uniqueElements: notifications), - runningScripts: runningScriptsProjection(), - ) - } - - /// Order-stable snapshot of the user scripts currently tracked in - /// `blockingScripts`; lifecycle kinds (archive / delete) carry no - /// definition ID and are excluded by construction. - private func runningScriptsProjection() -> IdentifiedArrayOf { - var scripts: IdentifiedArrayOf = [] - let definitions = blockingScripts.values - .compactMap { kind -> ScriptDefinition? in - guard case .script(let definition) = kind else { return nil } - return definition - } - .sorted { $0.id.uuidString < $1.id.uuidString } - for definition in definitions { - scripts.updateOrAppend(.init(id: definition.id, tint: definition.resolvedTintColor)) - } - return scripts - } - - private func scheduleRunningScriptsProjectionEmit() { - guard !pendingRunningScriptsProjectionEmit else { return } - pendingRunningScriptsProjectionEmit = true - Task { @MainActor [weak self] in - guard let self else { return } - self.pendingRunningScriptsProjectionEmit = false - self.onRunningScriptsChanged?() - } - } - - func isBlockingScriptRunning(kind: BlockingScriptKind) -> Bool { - blockingScripts.values.contains(kind) - } - - var hasInflightBlockingScripts: Bool { - !blockingScripts.isEmpty - } - - private func updateShouldHideTabBar() { - @Shared(.settingsFile) var settingsFile - // Force the bar visible on a split-zoomed single tab so the dismiss-zoom indicator has somewhere to live. - let wouldHide = settingsFile.global.hideSingleTabBar && tabManager.tabs.count == 1 - let newValue = wouldHide && !trees.values.contains { $0.zoomed != nil } - guard shouldHideTabBar != newValue else { return } - shouldHideTabBar = newValue - } - - func refreshTabBarVisibility() { - updateShouldHideTabBar() - } - - func isSplitZoomed(forTabID tabID: TerminalTabID) -> Bool { - trees[tabID]?.zoomed != nil - } - - func dismissSplitZoom(for tabID: TerminalTabID) { - guard let tree = trees[tabID], let zoomed = tree.zoomed else { return } - let previouslyZoomedSurface = zoomed.leftmostLeaf() - updateTree(tree.settingZoomed(nil), for: tabID) - focusLeaf(previouslyZoomedSurface, in: tabID) - } - - func ensureInitialTab(focusing: Bool) { - guard !hasAttemptedInitialTab else { return } - hasAttemptedInitialTab = true - guard tabManager.tabs.isEmpty else { return } - - if let snapshot = pendingLayoutSnapshot { - pendingLayoutSnapshot = nil - restoreFromSnapshot(snapshot, focusing: focusing) - return - } - let setupScript = pendingSetupScript ? repositorySettings.setupScript : nil - _ = createTab(focusing: focusing, setupScript: setupScript) - } - - @discardableResult - func createTab( - focusing: Bool = true, - setupScript: String? = nil, - initialInput: String? = nil, - inheritingFromSurfaceId: UUID? = nil, - tabID: UUID? = nil - ) -> TerminalTabID? { - let context: ghostty_surface_context_e = - tabManager.tabs.isEmpty - ? GHOSTTY_SURFACE_CONTEXT_WINDOW - : GHOSTTY_SURFACE_CONTEXT_TAB - let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() - let title = "\(worktree.name) \(nextTabIndex())" - let setupInput = terminal.setupScriptInput(setupScript: setupScript) - let commandInput = initialInput.flatMap { BlockingScriptRunner.makeCommandInput(script: $0) } - let resolvedInput: String? - switch (setupInput, commandInput) { - case (nil, nil): - resolvedInput = nil - case (let setupInput?, nil): - resolvedInput = setupInput - case (nil, let commandInput?): - resolvedInput = commandInput - case (let setupInput?, let commandInput?): - resolvedInput = setupInput + commandInput - } - let shouldConsumeSetupScript = pendingSetupScript && setupScript != nil - if shouldConsumeSetupScript { - pendingSetupScript = false - } - let tabId = createTab( - TabCreation( - title: title, - icon: nil, - isTitleLocked: false, - command: nil, - initialInput: resolvedInput, - focusing: focusing, - inheritingFromSurfaceId: resolvedInheritanceSurfaceId, - context: context, - tabID: tabID, - ) - ) - if shouldConsumeSetupScript, tabId != nil { - onSetupScriptConsumed?() - } - return tabId - } - - /// Stops a single user-defined script identified by its definition ID. - @discardableResult - func stopScript(definitionID: UUID) -> Bool { - guard - let tabId = blockingScripts.first(where: { $0.value.scriptDefinitionID == definitionID })?.key - else { return false } - closeTab(tabId) - return true - } - - /// Stops all running `.run`-kind scripts. Intentionally excludes - /// non-run scripts (test, deploy, etc.) because the Stop action - /// (Cmd+.) is the semantic counterpart of Run, not a "stop - /// everything" command. Other kinds are stopped individually - /// via the script menu or command palette. - @discardableResult - func stopRunScripts() -> Bool { - let runTabIds = blockingScripts.filter { $0.value.isRunKind }.map(\.key) - guard !runTabIds.isEmpty else { return false } - for tabId in runTabIds { - closeTab(tabId) - } - return true - } - - /// Returns the set of script definition IDs currently running. - func runningScriptDefinitionIDs() -> Set { - Set(blockingScripts.values.compactMap(\.scriptDefinitionID)) - } - - /// Checks whether a user-defined script with the given definition ID is running. - func isScriptRunning(definitionID: UUID) -> Bool { - blockingScripts.values.contains(where: { $0.scriptDefinitionID == definitionID }) - } - - @discardableResult - func runBlockingScript(kind: BlockingScriptKind, _ script: String) -> TerminalTabID? { - // A re-run of an already-tracked user script is a duplicate request, not a - // restart: keep the running instance (#573). Lifecycle kinds (archive / - // delete) keep their replace-on-rerun semantics. - if case .script = kind, - let active = blockingScripts.first(where: { $0.value == kind })?.key - { - // The early return skips the `blockingScripts` didSet, so emit explicitly - // to unstick a row whose projection was shed or stripped. - scheduleRunningScriptsProjectionEmit() - return active - } - // Resolve the surface command per host. A remote worktree runs the same - // OSC 133 framing on the host over ssh (no local temp files, no zmx wrap), - // so the script executes on the remote and not on a same-path local dir. - let command: String - let initialInput: String? - let launchDirectory: URL? - if let host = worktree.host { - guard - let remote = BlockingScriptRunner.remoteCommand( - host: host, - script: script, - remoteWorktreePath: worktree.workingDirectory.path(percentEncoded: false), - environment: terminal.blockingScriptEnvironment(for: kind) - ) - else { - reportBlockingScriptLaunchFailure(kind, "Failed to build remote \(kind.tabTitle) for worktree \(worktree.id)") - return nil - } - command = remote - initialInput = nil - launchDirectory = nil - } else { - let launch: BlockingScriptRunner.LaunchArtifacts - do { - guard let prepared = try terminal.blockingScriptLaunch(script) else { - reportBlockingScriptLaunchFailure( - kind, "Failed to prepare \(kind.tabTitle) for worktree \(worktree.id): empty script") - return nil - } - launch = prepared - } catch { - reportBlockingScriptLaunchFailure( - kind, "Failed to prepare \(kind.tabTitle) for worktree \(worktree.id): \(error)") - return nil - } - command = defaultShellPath() - initialInput = launch.commandInput - launchDirectory = launch.directoryURL - } - // Close any previous tab of the same kind: lingering from a completed or - // cancelled run, or (lifecycle kinds only) still active. Clear tracking - // state first so closeTab doesn't fire a premature completion callback. - if let active = blockingScripts.first(where: { $0.value == kind })?.key { - blockingScripts.removeValue(forKey: active) - lastBlockingScriptTabByKind.removeValue(forKey: kind) - closeTab(active) - } else if let lingering = lastBlockingScriptTabByKind.removeValue(forKey: kind) { - closeTab(lingering) - } - let tabId = createTab( - TabCreation( - title: kind.tabTitle, - icon: kind.tabIcon, - isTitleLocked: true, - tintColor: kind.tabColor, - command: command, - initialInput: initialInput, - focusing: true, - inheritingFromSurfaceId: currentFocusedSurfaceId(), - context: GHOSTTY_SURFACE_CONTEXT_TAB, - tabID: nil, - isBlockingScript: true, - blockingScriptKind: kind, - bypassZmx: true, - ) - ) - guard let tabId else { - if let launchDirectory { - terminal.cleanupBlockingScriptLaunchDirectory(at: launchDirectory) - } - reportBlockingScriptLaunchFailure(kind, "Failed to create \(kind.tabTitle) tab for worktree \(worktree.id)") - return nil - } - if let launchDirectory { - terminal.blockingScriptLaunchDirectories[tabId] = launchDirectory - } - lastBlockingScriptTabByKind[kind] = tabId - tabManager.updateDirty(tabId, isDirty: true) - emitTaskStatusIfChanged() - - blockingScriptLogger.info("Started \(kind.tabTitle) for worktree \(worktree.id)") - return tabId - } - - /// Report a launch that never produced a tab: exit 1 and no tab id, so the - /// caller gets an alert and a completion instead of a silent nil (#573). - private func reportBlockingScriptLaunchFailure(_ kind: BlockingScriptKind, _ message: String) { - blockingScriptLogger.warning(message) - onBlockingScriptCompleted?(kind, 1, nil) - } - - private struct TabCreation: Equatable { - let title: String - let icon: String? - let isTitleLocked: Bool - var tintColor: RepositoryColor? - let command: String? - let initialInput: String? - let focusing: Bool - let inheritingFromSurfaceId: UUID? - let context: ghostty_surface_context_e - let tabID: UUID? - /// Marks the tab as a blocking-script tab so the no-split / no-rename - /// / readonly-after-completion guardrails apply. - var isBlockingScript: Bool = false - /// The blocking-script kind, recorded into `blockingScripts` before the - /// surface is built so `surfaceEnvironment` can emit its env markers. - var blockingScriptKind: BlockingScriptKind? - /// Skip zmx session wrapping for transactional surfaces (blocking setup/archive/delete scripts) - /// that must die with the app rather than survive. - var bypassZmx: Bool = false - } - - private func createTab(_ creation: TabCreation) -> TerminalTabID? { - let tabId = tabManager.createTab( - title: creation.title, - icon: creation.icon, - isTitleLocked: creation.isTitleLocked, - tintColor: creation.tintColor, - isBlockingScript: creation.isBlockingScript, - id: creation.tabID, - ) - // Record the kind before the surface is built so `surfaceEnvironment` - // can read it when emitting the blocking-script env markers. - if let blockingScriptKind = creation.blockingScriptKind { - blockingScripts[tabId] = blockingScriptKind - } - // When a tab ID is explicitly provided, use it as the initial surface ID - // so the CLI can reference the surface immediately after creation. - let tree = splitTree( - for: tabId, - inheritingFromSurfaceId: creation.inheritingFromSurfaceId, - command: creation.command, - initialInput: creation.initialInput, - context: creation.context, - surfaceID: creation.tabID != nil ? tabId.rawValue : nil, - bypassZmx: creation.bypassZmx - ) - updateShouldHideTabBar() - if creation.focusing, let leaf = tree.root?.leftmostLeaf() { - focusLeaf(leaf, in: tabId) - } - onTabCreated?() - return tabId - } - - func listSurfaces(tabID: TerminalTabID) -> [[String: String]] { - let focusedID = focusedSurfaceIdByTab[tabID] - return surfaces.compactMap { surfaceID, _ in - guard self.tabID(containing: surfaceID) == tabID else { return nil } - var entry = ["id": surfaceID.uuidString] - if surfaceID == focusedID { entry["focused"] = "1" } - return entry - }.sorted { ($0["id"] ?? "") < ($1["id"] ?? "") } - } - - func hasTab(_ tabId: TerminalTabID) -> Bool { - tabManager.tabs.contains(where: { $0.id == tabId }) - } - - /// Surface IDs in a single tab (one entry per leaf of the tab's split tree). - /// Empty if the tab does not exist. - func surfaceIDs(inTab tabId: TerminalTabID) -> [UUID] { - trees[tabId]?.leaves().map(\.id) ?? [] - } - - /// All surface IDs across every tab in this worktree state. - var allSurfaceIDs: [UUID] { - trees.values.flatMap { $0.leaves().map(\.id) } - } - - /// Host of a remote worktree, nil for local. Every surface in this state - /// shares it, so teardown paths can target the host-side zmx sessions. - var remoteHost: RemoteHost? { - worktree.host - } - - // Standardized to match `loadFailuresByID` keys (built from `standardizedFileURL.path`) - // so prune protection lines up. - var repositoryID: Repository.ID { - switch worktree.location.repositoryLocation { - case .local(let url): - RepositoryID(url.standardizedFileURL.path(percentEncoded: false)) - case .remote: - worktree.location.repositoryLocation.id - } - } - - /// O(1) emptiness check that skips the split-tree walk in `allSurfaceIDs`. - var hasAnySurface: Bool { !surfaces.isEmpty } - - func hasSurface(_ surfaceID: UUID, in tabId: TerminalTabID) -> Bool { - guard let tree = trees[tabId] else { return false } - return tree.find(id: surfaceID) != nil - } - - /// Checks whether a surface UUID exists anywhere in the worktree (across all tabs). - func hasSurfaceAnywhere(_ surfaceID: UUID) -> Bool { - surfaces[surfaceID] != nil - } - - func selectTab(_ tabId: TerminalTabID) { - guard tabManager.tabs.contains(where: { $0.id == tabId }) else { - terminalStateLogger.warning("selectTab: tab \(tabId.rawValue) not found in worktree \(worktree.id).") - return - } - let previousSelectedTabId = tabManager.selectedTabId - tabManager.selectTab(tabId) - focusSurface(in: tabId) - // Re-emit the stripe progress for both old and new selected tabs: their - // "focused vs aggregate" branch just flipped. - if let previousSelectedTabId, previousSelectedTabId != tabId { - emitTabProgressDisplay(for: previousSelectedTabId) - } - emitTabProgressDisplay(for: tabId) - emitTaskStatusIfChanged() - } - - func focusSelectedTab() { - guard let tabId = tabManager.selectedTabId else { return } - focusSurface(in: tabId) - } - - func focusAndInsertText(_ text: String) { - guard let tabId = tabManager.selectedTabId, - let focusedId = focusedSurfaceIdByTab[tabId], - let surface = surfaces[focusedId] - else { - terminalStateLogger.warning("focusAndInsertText: no focused surface") - return - } - terminalStateLogger.info("focusAndInsertText: sending \(text.count) chars to surface \(focusedId)") - surface.requestFocus() - surface.sendText(text) - } - - func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { - lastWindowIsKey = windowIsKey - lastWindowIsVisible = windowIsVisible - applySurfaceActivity() - } - - private func applySurfaceActivity() { - let selectedTabId = tabManager.selectedTabId - var surfaceToFocus: GhosttySurfaceView? - for (tabId, tree) in trees { - let focusedId = focusedSurfaceIdByTab[tabId] - let isSelectedTab = (tabId == selectedTabId) - let visibleSurfaceIDs = Set(tree.visibleLeaves().map(\.id)) - for leaf in tree.leaves() { - switch leaf.content { - case .terminal(let surface): - let activity = Self.surfaceActivity( - isSurfaceVisibleInTree: visibleSurfaceIDs.contains(surface.id), - isSelectedTab: isSelectedTab, - windowIsVisible: lastWindowIsVisible == true, - windowIsKey: lastWindowIsKey == true, - focusedSurfaceID: focusedId, - surfaceID: surface.id - ) - surface.setOcclusion(activity.isVisible) - surface.focusDidChange(activity.isFocused) - if activity.isFocused { - surfaceToFocus = surface - } - } - } - } - if let surfaceToFocus, surfaceToFocus.window?.firstResponder is GhosttySurfaceView { - surfaceToFocus.window?.makeFirstResponder(surfaceToFocus) - } - } - - static func surfaceActivity( - isSurfaceVisibleInTree: Bool = true, - isSelectedTab: Bool, - windowIsVisible: Bool, - windowIsKey: Bool, - focusedSurfaceID: UUID?, - surfaceID: UUID - ) -> SurfaceActivity { - let isVisible = isSurfaceVisibleInTree && isSelectedTab && windowIsVisible - let isFocused = isVisible && windowIsKey && focusedSurfaceID == surfaceID - return SurfaceActivity(isVisible: isVisible, isFocused: isFocused) - } - - @discardableResult - func focusSurface(id: UUID) -> Bool { - guard let tabId = tabID(containing: id), - let surface = surfaces[id] - else { - terminalStateLogger.warning("focusSurface: surface \(id) not found in worktree \(worktree.id).") - return false - } - tabManager.selectTab(tabId) - focusSurface(surface, in: tabId) - return true - } - - @discardableResult - func closeFocusedTab() -> Bool { - guard let tabId = tabManager.selectedTabId else { return false } - closeTab(tabId) - return true - } - - @discardableResult - func closeFocusedSurface() -> Bool { - guard let tabId = tabManager.selectedTabId, - let focusedId = focusedSurfaceIdByTab[tabId], - let surface = surfaces[focusedId] - else { - return false - } - requestExplicitSurfaceClose(surface) - return true - } - - @discardableResult - func closeSurface(id surfaceID: UUID) -> Bool { - guard let surface = surfaces[surfaceID] else { - terminalStateLogger.warning( - "closeSurface: surface \(surfaceID) not found. Known: \(surfaces.keys.map(\.uuidString))") - return false - } - requestExplicitSurfaceClose(surface) - return true - } - - private func requestExplicitSurfaceClose(_ surface: GhosttySurfaceView) { - performBindingAction("close_surface", on: surface) - } - - @discardableResult - func performBindingActionOnFocusedSurface(_ action: String) -> Bool { - guard let tabId = tabManager.selectedTabId, - let focusedId = focusedSurfaceIdByTab[tabId], - let surface = surfaces[focusedId] - else { - return false - } - performBindingAction(action, on: surface) - return true - } - - @discardableResult - func performBindingAction(_ action: String, onSurfaceID surfaceID: UUID) -> Bool { - guard let surface = surfaces[surfaceID] else { return false } - performBindingAction(action, on: surface) - return true - } - - @discardableResult - func setImagePasteAgents(_ agents: Set, onSurfaceID surfaceID: UUID) -> Bool { - guard let surface = surfaces[surfaceID] else { return false } - surface.imagePasteAgents = agents - return true - } - - private func performBindingAction(_ action: String, on surface: GhosttySurfaceView) { - if action == "close_surface" { - pendingExplicitSurfaceCloseIDs.insert(surface.id) - } - surface.performBindingAction(action) - } - - @discardableResult - func navigateSearchOnFocusedSurface(_ direction: GhosttySearchDirection) -> Bool { - guard let tabId = tabManager.selectedTabId, - let focusedId = focusedSurfaceIdByTab[tabId], - let surface = surfaces[focusedId] - else { - return false - } - surface.navigateSearch(direction) - return true - } - - func closeTab(_ tabId: TerminalTabID) { - let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) - terminal.cleanupBlockingScriptLaunchDirectory(for: tabId) - // Clear lingering tab tracking for completed or non-blocking tabs. - for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { - lastBlockingScriptTabByKind.removeValue(forKey: kind) - } - removeTree(for: tabId) - tabManager.closeTab(tabId) - updateShouldHideTabBar() - if let selected = tabManager.selectedTabId { - focusSurface(in: selected) - } else { - lastEmittedFocusSurfaceId = nil - } - emitTaskStatusIfChanged() - - if let closedBlockingKind { - blockingScriptLogger.info("\(closedBlockingKind.tabTitle) cancelled (tab closed)") - onBlockingScriptCompleted?(closedBlockingKind, nil, nil) - } - onTabClosed?() - } - - /// User-initiated rename. Routes through the manager so the new title (or its - /// removal on an empty commit) persists incrementally, unlike the restore path - /// which seeds `setCustomTitle` directly from a snapshot. - func renameTab(_ tabId: TerminalTabID, title: String) { - tabManager.setCustomTitle(tabId, title: title) - onTabRenamed?() - } - - func closeOtherTabs(keeping tabId: TerminalTabID) { - let ids = tabManager.tabs.map(\.id).filter { $0 != tabId } - for id in ids { - closeTab(id) - } - } - - func closeTabsToRight(of tabId: TerminalTabID) { - guard let index = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } - let ids = tabManager.tabs.dropFirst(index + 1).map(\.id) - for id in ids { - closeTab(id) - } - } - - func closeAllTabs() { - let ids = tabManager.tabs.map(\.id) - for id in ids { - closeTab(id) - } - } - - func splitTree( - for tabId: TerminalTabID, - inheritingFromSurfaceId: UUID? = nil, - command: String? = nil, - initialInput: String? = nil, - context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB, - surfaceID: UUID? = nil, - bypassZmx: Bool = false - ) -> SplitTree { - if let existing = trees[tabId] { - return existing - } - // A stale render of a just-closed tab (removal transition) must not lazily - // resurrect it: the replacement surface would be invisible, unclosable, and - // hold its local and host zmx sessions alive. - guard hasTab(tabId) else { return SplitTree() } - let surface = createSurface( - tabId: tabId, - command: command, - initialInput: initialInput, - inheritingFromSurfaceId: inheritingFromSurfaceId, - context: context, - surfaceID: surfaceID, - bypassZmx: bypassZmx - ) - let tree = SplitTree(view: surface) - setTree(tree, for: tabId) - setFocusedSurface(surface.id, for: tabId) - return tree - } - - func performSplitAction( - _ action: GhosttySplitAction, - for surfaceID: UUID, - newSurfaceID: UUID? = nil, - initialInput: String? = nil - ) -> Bool { - guard let tabId = tabID(containing: surfaceID), var tree = trees[tabId] else { - return false - } - guard let targetNode = tree.find(id: surfaceID) else { return false } - guard let targetSurface = surfaces[surfaceID] else { return false } - - switch action { - case .newSplit(let direction): - // Splits would leak a zmx-wrapped sibling into a transactional tab. - // Refuse before allocating a surface so the tab stays single-pane. - if tabManager.isBlockingScript(tabId) { - return false - } - let newSurface = createSurface( - tabId: tabId, - initialInput: initialInput, - inheritingFromSurfaceId: surfaceID, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - surfaceID: newSurfaceID, - ) - do { - let newTree = try tree.inserting( - view: newSurface, - at: targetSurface, - direction: mapSplitDirection(direction) - ) - updateTree(newTree, for: tabId) - focusSurface(newSurface, in: tabId) - return true - } catch { - terminalStateLogger.warning( - "performSplitAction: failed to insert split for surface \(surfaceID) in tab \(tabId.rawValue): \(error)") - newSurface.closeSurface() - discardSurfaceBookkeeping(for: newSurface.id) - return false - } - - case .gotoSplit(let direction): - let focusDirection = mapFocusDirection(direction) - guard let nextSurface = tree.focusTarget(for: focusDirection, from: targetNode) else { - return false - } - if tree.zoomed != nil { - if splitPreserveZoomOnNavigation() { - let nextNode = tree.root?.node(view: nextSurface) - tree = tree.settingZoomed(nextNode) - } else { - tree = tree.settingZoomed(nil) - } - updateTree(tree, for: tabId) - } - focusLeaf(nextSurface, in: tabId) - syncFocusIfNeeded() - return true - - case .resizeSplit(let direction, let amount): - let spatialDirection = mapResizeDirection(direction) - do { - let newTree = try tree.resizing( - node: targetNode, - by: amount, - in: spatialDirection, - with: CGRect(origin: .zero, size: tree.viewBounds()) - ) - updateTree(newTree, for: tabId) - return true - } catch { - return false - } - - case .equalizeSplits: - updateTree(tree.equalized(), for: tabId) - return true - - case .toggleSplitZoom: - guard tree.isSplit else { return false } - let newZoomed = (tree.zoomed == targetNode) ? nil : targetNode - updateTree(tree.settingZoomed(newZoomed), for: tabId) - focusSurface(targetSurface, in: tabId) - return true - } - } - - func performSplitOperation(_ operation: TerminalSplitTreeView.Operation, in tabId: TerminalTabID) { - guard var tree = trees[tabId] else { return } - // Drag-to-drop surfaces from other tabs into a blocking-script tab would - // introduce a zmx-wrapped sibling. Same rationale as the `newSplit` guard. - if case .drop = operation, tabManager.isBlockingScript(tabId) { return } - - switch operation { - case .resize(let node, let ratio): - let resizedNode = node.resizing(to: ratio) - do { - tree = try tree.replacing(node: node, with: resizedNode) - updateTree(tree, for: tabId) - } catch { - return - } - - case .drop(let payloadId, let destinationId, let zone): - // Resolve through the tab's tree (content-agnostic), not the terminal-only - // `surfaces` map: a drop only ever rearranges leaves within this tab. - let leaves = tree.visibleLeaves() - guard let payload = leaves.first(where: { $0.id == payloadId }), - let destination = leaves.first(where: { $0.id == destinationId }), - payload !== destination, - let sourceNode = tree.root?.node(view: payload) - else { return } - let treeWithoutSource = tree.removing(sourceNode) - if treeWithoutSource.isEmpty { return } - do { - let newTree = try treeWithoutSource.inserting( - view: payload, - at: destination, - direction: mapDropZone(zone) - ) - updateTree(newTree, for: tabId) - focusLeaf(payload, in: tabId) - } catch { - return - } - - case .equalize: - updateTree(tree.equalized(), for: tabId) - } - } - - func setAllSurfacesOccluded() { - for surface in surfaces.values { - surface.setOcclusion(false) - surface.focusDidChange(false) - } - } - - func closeAllSurfaces() { - let closingSurfaces = Array(surfaces.values) - let closingSurfaceIDs = closingSurfaces.map(\.id) - for surface in closingSurfaces { - surface.closeSurface() - } - for surfaceID in closingSurfaceIDs { - discardSurfaceBookkeeping(for: surfaceID) - } - terminal.cleanupBlockingScriptLaunchDirectories() - trees.removeAll() - surfaceGenerationByTab.removeAll() - focusedSurfaceIdByTab.removeAll() - onSurfacesClosed?(Set(closingSurfaceIDs)) - let pendingKinds = Set(blockingScripts.values) - blockingScripts.removeAll() - lastBlockingScriptTabByKind.removeAll() - - for kind in pendingKinds { - onBlockingScriptCompleted?(kind, nil, nil) - } - tabManager.closeAll() - // Drain per-tab caches and notify so `TerminalsFeature.State.terminalTabs` - // entries don't leak for tabs in a torn-down worktree (#289 follow-up). - let removedTabIDs = Array(lastTabProjections.keys) - lastTabProjections.removeAll() - lastTabProgressDisplays.removeAll() - for tabID in removedTabIDs { - onTabRemoved?(tabID) - } - } - - func setNotificationsEnabled(_ enabled: Bool) { - notificationsEnabled = enabled - if !enabled { - markAllNotificationsRead() - } - } - - func clearNotificationIndicator() { - markAllNotificationsRead() - } - - func markAllNotificationsRead() { - for index in notifications.indices { - notifications[index].isRead = true - } - clearAllSurfaceUnseenFlags() - emitAllTabProjections() - emitNotificationStateChanged() - } - - func markNotificationsRead(forSurfaceID surfaceID: UUID) { - for index in notifications.indices where notifications[index].surfaceID == surfaceID { - notifications[index].isRead = true - } - setSurfaceUnseenFlag(surfaceID, to: false) - if let tabId = tabID(containing: surfaceID) { - emitTabProjection(for: tabId) - } - emitNotificationStateChanged() - } - - /// Marks a single notification as read, leaving others untouched. - func markNotificationRead(id: WorktreeTerminalNotification.ID) { - guard let index = notifications.firstIndex(where: { $0.id == id }) else { return } - guard !notifications[index].isRead else { return } - let surfaceID = notifications[index].surfaceID - notifications[index].isRead = true - refreshSurfaceUnseenFlag(surfaceID) - if let tabId = tabID(containing: surfaceID) { - emitTabProjection(for: tabId) - } - emitNotificationStateChanged() - } - - func dismissNotification(_ notificationID: WorktreeTerminalNotification.ID) { - let affectedSurface = notifications.first(where: { $0.id == notificationID })?.surfaceID - notifications.removeAll { $0.id == notificationID } - if let affectedSurface { - refreshSurfaceUnseenFlag(affectedSurface) - if let tabId = tabID(containing: affectedSurface) { - emitTabProjection(for: tabId) - } - } - emitNotificationStateChanged() - } - - func dismissAllNotifications() { - notifications.removeAll() - clearAllSurfaceUnseenFlags() - emitAllTabProjections() - emitNotificationStateChanged() - } - - /// Recomputes the surface's unseen flag through the canonical predicate so a - /// future tweak to `hasUnseenNotification(forSurfaceID:)` is picked up here - /// without a parallel branch silently drifting. - private func refreshSurfaceUnseenFlag(_ surfaceID: UUID) { - setSurfaceUnseenFlag(surfaceID, to: hasUnseenNotification(forSurfaceID: surfaceID)) - } - - private func setSurfaceUnseenFlag(_ surfaceID: UUID, to value: Bool) { - guard let state = surfaceStates[surfaceID] else { return } - guard state.hasUnseenNotification != value else { return } - state.hasUnseenNotification = value - } - - private func clearAllSurfaceUnseenFlags() { - for state in surfaceStates.values where state.hasUnseenNotification { - state.hasUnseenNotification = false - } - } - - // MARK: - Layout Snapshot - - /// Capture a layout snapshot, optionally embedding per-surface agent - /// presence records. The caller (AppDelegate's `applicationWillTerminate` - /// path) reads `AppFeature.State.agentPresence.records` and converts it - /// into the per-surface dict before invoking this so agents persist - /// atomically with their owning surface and vanish on prune. - func captureLayoutSnapshot( - agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] = [:] - ) -> TerminalLayoutSnapshot? { - guard !tabManager.tabs.isEmpty else { return nil } - var tabSnapshots: [TerminalLayoutSnapshot.TabSnapshot] = [] - for tab in tabManager.tabs { - // Blocking-script tabs die with the app; persisting them would resurrect a dead session. - if tab.isBlockingScript { continue } - guard let tree = trees[tab.id], let root = tree.root else { - layoutLogger.warning("Skipping tab \(tab.id.rawValue) during snapshot capture (no tree)") - continue - } - let layout = captureLayoutNode(root, agentsBySurface: agentsBySurface) - let leaves = root.leaves() - let focusedId = focusedSurfaceIdByTab[tab.id] - let focusedLeafIndex = - focusedId.flatMap { id in - leaves.firstIndex(where: { $0.id == id }) - } ?? 0 - tabSnapshots.append( - TerminalLayoutSnapshot.TabSnapshot( - id: tab.id.rawValue, - title: tab.title, - customTitle: tab.customTitle, - icon: tab.icon, - tintColor: tab.tintColor, - layout: layout, - focusedLeafIndex: focusedLeafIndex, - ) - ) - } - guard !tabSnapshots.isEmpty else { return nil } - // Walk against the surviving tabs (post-filter), preferring the nearest - // left neighbor when the originally-selected tab was excluded. If every - // left neighbor is also excluded, fall through to the leftmost surviving - // tab. Computing against `tabManager.tabs` would land on the wrong - // neighbor for `[A, B(blocking, selected), C]`. - let selectedIndex: Int = { - guard let selectedID = tabManager.selectedTabId else { return 0 } - if let direct = tabSnapshots.firstIndex(where: { $0.id == selectedID.rawValue }) { - return direct - } - guard let originalIndex = tabManager.tabs.firstIndex(where: { $0.id == selectedID }) else { - return 0 - } - for index in stride(from: originalIndex - 1, through: 0, by: -1) { - let candidate = tabManager.tabs[index] - if let surviving = tabSnapshots.firstIndex(where: { $0.id == candidate.id.rawValue }) { - return surviving - } - } - return 0 - }() - return TerminalLayoutSnapshot(tabs: tabSnapshots, selectedTabIndex: selectedIndex) - } - - private func captureLayoutNode( - _ node: SplitTree.Node, - agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] - ) -> TerminalLayoutSnapshot.LayoutNode { - switch node { - case .leaf(let view): - switch view.content { - case .terminal(let surface): - return .leaf( - .terminal( - id: surface.id, - workingDirectory: surface.bridge.state.pwd, - agents: agentsBySurface[surface.id] - ) - ) - } - case .split(let split): - let direction: SplitDirection = - switch split.direction { - case .horizontal: .horizontal - case .vertical: .vertical - } - return .split( - TerminalLayoutSnapshot.SplitSnapshot( - direction: direction, - ratio: split.ratio, - left: captureLayoutNode(split.left, agentsBySurface: agentsBySurface), - right: captureLayoutNode(split.right, agentsBySurface: agentsBySurface) - ) - ) - } - } - - private func restoreFromSnapshot(_ snapshot: TerminalLayoutSnapshot, focusing: Bool) { - guard !snapshot.tabs.isEmpty else { - layoutLogger.warning("Attempted to restore empty layout snapshot, skipping restoration.") - return - } - - // Skip setup script when restoring a saved layout. - pendingSetupScript = false - - for (index, tabSnapshot) in snapshot.tabs.enumerated() { - // Kind dispatch on the persisted leaf; a new snapshot kind adds a case. - let firstLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot - switch tabSnapshot.layout.firstLeaf { - case .terminal(let leaf): firstLeaf = leaf - } - let workingDir = firstLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } - let context: ghostty_surface_context_e = - index == 0 ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB - let tabId = tabManager.createTab( - title: tabSnapshot.title, - icon: tabSnapshot.icon, - isTitleLocked: false, - tintColor: tabSnapshot.tintColor, - id: tabSnapshot.id, - ) - if let customTitle = tabSnapshot.customTitle { - tabManager.setCustomTitle(tabId, title: customTitle) - } - let surface = createSurface( - tabId: tabId, - initialInput: nil, - workingDirectoryOverride: workingDir, - inheritingFromSurfaceId: nil, - context: context, - surfaceID: firstLeaf.id, - ) - let tree = SplitTree(view: surface) - setTree(tree, for: tabId) - setFocusedSurface(surface.id, for: tabId) - - // Recursively restore splits. - restoreLayoutNode(tabSnapshot.layout, anchor: surface, tabId: tabId) - - // Log if partial restoration produced fewer panes than expected. - let leaves = trees[tabId]?.root?.leaves() ?? [] - let expectedLeaves = tabSnapshot.layout.leafCount - if leaves.count != expectedLeaves { - layoutLogger.warning( - "Partial restore for tab '\(tabSnapshot.title)': expected \(expectedLeaves) panes, got \(leaves.count)" - ) - } - - // Focus the correct leaf. - let focusedIndex = max(0, min(tabSnapshot.focusedLeafIndex, leaves.count - 1)) - if focusedIndex < leaves.count { - setFocusedSurface(leaves[focusedIndex].id, for: tabId) - } - - onTabCreated?() - } - - // Seed image-paste routing from the snapshot's per-surface agent records, matching - // the presence restore's liveness filter: an unopened worktree drains its rehydrate - // fan-out before the surface exists, and a dead-pid record never gets a corrective - // empty fan-out, so seeding only live agents keeps Cmd+V from routing into a stale shell. - for record in snapshot.allAgentRecords() { - let liveAgents = record.records.compactMap { - $0.pids.contains(where: AgentPresenceFeature.isAlive) ? SkillAgent(rawValue: $0.agent) : nil - } - guard !liveAgents.isEmpty else { continue } - surfaces[record.surfaceID]?.imagePasteAgents = Set(liveAgents) - } - - // Select the correct tab. - let selectedIndex = max(0, min(snapshot.selectedTabIndex, tabManager.tabs.count - 1)) - if selectedIndex < tabManager.tabs.count { - let selectedTab = tabManager.tabs[selectedIndex] - tabManager.selectTab(selectedTab.id) - if focusing { - focusSurface(in: selectedTab.id) - } - } - - // Notifications outlive surfaces, so re-derive the freshly minted - // `WorktreeSurfaceState` flags or the per-surface dot stays dark after restore. - for surfaceID in Set(notifications.map(\.surfaceID)) { - refreshSurfaceUnseenFlag(surfaceID) - } - } - - private func restoreLayoutNode( - _ node: TerminalLayoutSnapshot.LayoutNode, - anchor: GhosttySurfaceView, - tabId: TerminalTabID - ) { - guard case .split(let split) = node else { return } - - // Create the right child by splitting the anchor. Kind dispatch on the - // persisted leaf; a new snapshot kind adds a case. - let rightLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot - switch split.right.firstLeaf { - case .terminal(let leaf): rightLeaf = leaf - } - let rightWorkingDir = rightLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } - let direction: SplitTree.NewDirection = - split.direction == .horizontal ? .right : .down - - guard - let newSurface = createRestorationSplit( - at: anchor, - direction: direction, - ratio: split.ratio, - workingDirectory: rightWorkingDir, - tabId: tabId, - surfaceID: rightLeaf.id, - ) - else { - layoutLogger.warning("Skipping subtree restoration for tab \(tabId.rawValue)") - return - } - - // Recurse into left and right subtrees. - restoreLayoutNode(split.left, anchor: anchor, tabId: tabId) - restoreLayoutNode(split.right, anchor: newSurface, tabId: tabId) - } - - private func createRestorationSplit( - at anchor: GhosttySurfaceView, - direction: SplitTree.NewDirection, - ratio: Double, - workingDirectory: URL?, - tabId: TerminalTabID, - surfaceID: UUID? = nil - ) -> GhosttySurfaceView? { - guard var tree = trees[tabId] else { return nil } - let newSurface = createSurface( - tabId: tabId, - initialInput: nil, - workingDirectoryOverride: workingDirectory, - inheritingFromSurfaceId: anchor.id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - surfaceID: surfaceID, - ) - do { - tree = try tree.inserting(view: newSurface, at: anchor, direction: direction, ratio: ratio) - setTree(tree, for: tabId) - return newSurface - } catch { - layoutLogger.warning("Failed to restore split for tab \(tabId.rawValue): \(error)") - newSurface.closeSurface() - discardSurfaceBookkeeping(for: newSurface.id) - return nil - } - } - - func needsSetupScript() -> Bool { - pendingSetupScript - } - - func enableSetupScriptIfNeeded() { - if pendingSetupScript { - return - } - if tabManager.tabs.isEmpty { - pendingSetupScript = true - } - } - - // Fires when the blocking command finishes. The shell stays alive - // so the user can inspect output. Completion is reported here for - // all exit codes. `handleBlockingScriptChildExited` covers the - // separate case where the shell exits before the command finishes. - private func handleBlockingScriptCommandFinished(tabId: TerminalTabID, exitCode: Int?) { - guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } - blockingScriptLogger.info("\(kind.tabTitle) finished with exit code \(exitCode.map(String.init) ?? "nil")") - completeBlockingScript(kind, tabId: tabId, exitCode: exitCode, reportedTabId: tabId) - } - - // Shell self-exit. A finished command already cleared tracking in - // `handleBlockingScriptCommandFinished`, so this no-ops. Local: user quit - // (exit / Ctrl+D), a cancellation. Remote: the child is ssh, so a failed run. - private func handleBlockingScriptChildExited(tabId: TerminalTabID, exitCode: UInt32) { - guard let kind = blockingScripts.removeValue(forKey: tabId) else { return } - // Remote ssh exit codes are unreliable (login wrapper); force failure so a - // raw 0 can't hit a lifecycle success path, and report no tab (ghostty - // already closed the surface). - guard worktree.host == nil else { - blockingScriptLogger.warning("\(kind.tabTitle) ssh exited before completion (raw exit code \(exitCode))") - completeBlockingScript(kind, tabId: tabId, exitCode: 1, reportedTabId: nil) - return - } - blockingScriptLogger.info( - "\(kind.tabTitle) cancelled (shell exited before command finished, raw exit code \(exitCode))" - ) - completeBlockingScript(kind, tabId: tabId, exitCode: nil, reportedTabId: nil) - } - - // Marks the blocking-script tab as completed and flips every surface in - // it to Ghostty's readonly mode so the user can't keep typing into a - // shell that won't survive app quit. Fires the completion callback - // asynchronously unless a new script of the same kind already started. - private func completeBlockingScript( - _ kind: BlockingScriptKind, - tabId: TerminalTabID, - exitCode: Int?, - reportedTabId: TerminalTabID? - ) { - tabManager.markBlockingScriptCompleted(tabId) - freezeBlockingScriptSurfaces(in: tabId) - emitTaskStatusIfChanged() - - Task { @MainActor [weak self] in - guard let self else { - blockingScriptLogger.debug("\(kind.tabTitle) completion dropped (state deallocated)") - return - } - guard !self.blockingScripts.values.contains(kind) else { - blockingScriptLogger.info("\(kind.tabTitle) completion superseded by new script of same kind") - return - } - self.onBlockingScriptCompleted?(kind, exitCode, reportedTabId) - } - } - - private func freezeBlockingScriptSurfaces(in tabId: TerminalTabID) { - for surfaceID in surfaceIDs(inTab: tabId) { - surfaces[surfaceID]?.enableReadOnly() - } - } - - private func createSurface( - tabId: TerminalTabID, - command: String? = nil, - initialInput: String?, - workingDirectoryOverride: URL? = nil, - inheritingFromSurfaceId: UUID?, - context: ghostty_surface_context_e, - surfaceID: UUID? = nil, - bypassZmx: Bool = false, - replacingExistingSurfaceID: Bool = false, - ) -> GhosttySurfaceView { - let resolvedID: UUID - if let requested = surfaceID { - if surfaces[requested] != nil, !replacingExistingSurfaceID { - terminalStateLogger.warning("Duplicate surface ID \(requested), generating a new one.") - resolvedID = UUID() - } else { - resolvedID = requested - } - } else { - resolvedID = UUID() - } - let surfaceID = resolvedID - terminalStateLogger.info("createSurface: resolved=\(surfaceID)") - let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) - let launch = terminal.resolveLaunch( - surfaceID: surfaceID, - command: command, - initialInput: initialInput, - bypassZmx: bypassZmx, - ) - // Remote worktrees have no local working directory: the surface command is - // an `ssh …` line (see `resolveLaunch`) and the cwd lives on the - // remote, so leave `working_directory` nil and let the remote shell `cd`. - let resolvedWorkingDirectory: URL? = - worktree.host == nil - ? (workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory) - : nil - let view = GhosttySurfaceView( - id: surfaceID, - runtime: runtime, - workingDirectory: resolvedWorkingDirectory, - command: launch.command, - initialInput: launch.initialInput, - environmentVariables: terminal.surfaceEnvironment(tabId: tabId, surfaceID: surfaceID), - commandWrapper: launch.commandWrapper, - // Blocking-script runners (bypassZmx) emit their own OSC 133/7 and must - // not get Ghostty's shell integration injected into the host shell. - disableShellIntegration: bypassZmx, - fontSize: inherited.fontSize ?? rememberedZoomFontSize, - context: context - ) - wireSurfaceCallbacks(view: view, tabId: tabId) - surfaces[view.id] = view - surfaceLaunchMetadata[view.id] = .init(usesZmx: launch.usesZmx, context: context) - surfaceStates[view.id] = WorktreeSurfaceState() - return view - } - - /// Extracted from `createSurface` so the latter stays under swiftlint's - /// cyclomatic-complexity cap. The closures all branch on `[weak self, - /// weak view]` so the count adds up fast. - private func wireSurfaceCallbacks( - view: GhosttySurfaceView, - tabId: TerminalTabID - ) { - wireSurfaceTabCallbacks(view: view, tabId: tabId) - wireSurfaceLifecycleCallbacks(view: view, tabId: tabId) - } - - /// Tab / title / split callbacks. Split from `wireSurfaceLifecycleCallbacks` - /// so each stays under swiftlint's cyclomatic-complexity cap. - private func wireSurfaceTabCallbacks( - view: GhosttySurfaceView, - tabId: TerminalTabID - ) { - view.bridge.onTitleChange = { [weak self, weak view] title in - guard let self, let view else { return } - guard self.isLiveSurface(view) else { return } - if self.focusedSurfaceIdByTab[tabId] == view.id { - self.tabManager.updateTitle(tabId, title: title) - } - } - view.bridge.onPromptTitle = { [weak self, weak view] in - guard let self, let view, self.isLiveSurface(view) else { return } - self.tabManager.beginTabRename(tabId) - } - view.bridge.onSplitAction = { [weak self, weak view] action in - guard let self, let view else { return false } - guard self.isLiveSurface(view) else { return false } - return self.performSplitAction(action, for: view.id) - } - view.bridge.onNewTab = { [weak self, weak view] in - guard let self, let view else { return false } - guard self.isLiveSurface(view) else { return false } - return self.createTab(inheritingFromSurfaceId: view.id) != nil - } - view.bridge.onCloseTab = { [weak self, weak view] _ in - guard let self, let view, self.isLiveSurface(view) else { return false } - self.closeTab(tabId) - return true - } - view.bridge.onGotoTab = { [weak self, weak view] target in - guard let self, let view, self.isLiveSurface(view) else { return false } - return self.handleGotoTabRequest(target) - } - view.bridge.onCommandPaletteToggle = { [weak self, weak view] in - guard let self, let view, self.isLiveSurface(view) else { return false } - self.onCommandPaletteToggle?() - return true - } - } - - /// Progress / exit / notification / focus callbacks. - private func wireSurfaceLifecycleCallbacks( - view: GhosttySurfaceView, - tabId: TerminalTabID - ) { - view.bridge.onProgressReport = { [weak self, weak view] _ in - guard let self, let view, self.isLiveSurface(view) else { return } - self.updateRunningState(for: tabId) - } - view.bridge.onCommandFinished = { [weak self, weak view] exitCode in - guard let self, let view, self.isLiveSurface(view) else { return } - self.handleBlockingScriptCommandFinished(tabId: tabId, exitCode: exitCode) - } - view.bridge.onChildExited = { [weak self, weak view] exitCode in - guard let self, let view, self.isLiveSurface(view) else { return } - self.handleBlockingScriptChildExited(tabId: tabId, exitCode: exitCode) - } - view.bridge.onDesktopNotification = { [weak self, weak view] title, body in - guard let self, let view else { return } - guard self.isLiveSurface(view) else { return } - self.handleAgentOSCNotification(title: title, body: body, surfaceID: view.id) - } - view.bridge.onContextSignal = { [weak self, weak view] _, id, metadata in - guard let self, let view else { return } - guard self.isLiveSurface(view) else { return } - self.handleContextSignal(surfaceID: view.id, id: id, metadata: metadata) - } - view.bridge.onCloseRequest = { [weak self, weak view] _ in - guard let self, let view else { return } - self.handleCloseRequest(for: view) - } - view.onFocusChange = { [weak self, weak view] focused in - guard let self, let view, focused else { return } - guard self.isLiveSurface(view) else { return } - self.recordActiveSurface(view, in: tabId) - self.emitTaskStatusIfChanged() - } - view.bridge.onColorChanged = { [weak self, weak view] in - guard let self, let view, self.isLiveSurface(view) else { return } - // Only the focused surface drives the window tint. - guard self.focusedSurfaceIdByTab[tabId] == view.id else { return } - self.onFocusedSurfaceColorChanged?() - } - view.shouldClaimFocus = { [weak self, weak view] in - guard let self, let view, self.isLiveSurface(view) else { return false } - return self.focusedSurfaceIdByTab[tabId] == view.id - } - } - - // Identity, not key presence: a reattached surface keeps its UUID, so stale closures from the old view must no-op. - private func isLiveSurface(_ view: GhosttySurfaceView) -> Bool { - surfaces[view.id] === view - } - - // The bridge state of the focused surface in the selected tab, if any. Used to - // resolve the window tint from the focused surface's OSC 11 background. - func focusedSurfaceState() -> GhosttySurfaceState? { - guard let tabID = tabManager.selectedTabId, - let surfaceID = focusedSurfaceIdByTab[tabID], - let surface = surfaces[surfaceID] - else { return nil } - return surface.bridge.state - } - - /// Routes an OSC 3008 context signal to the presence or notify handler. - private func handleContextSignal(surfaceID: UUID, id: String, metadata: String) { - // Route by notify INTENT, not by parse success, so a malformed notify logs as - // a notify drop rather than silently falling through to the presence handler. - if AgentPresenceOSC.isNotifyMetadata(metadata) { - handleNotifySignal(surfaceID: surfaceID, id: id, metadata: metadata) - } else { - handlePresenceSignal(surfaceID: surfaceID, id: id, metadata: metadata) - } - } - - /// Verify an OSC 3008 presence signal against the receiving surface's nonce, - /// then synthesize an `AgentHookEvent` and forward it to the manager. Attribution - /// is by the receiving surface, so the wire never carries a surface id that could - /// spoof another worktree's badge; a pid rides along only for local hooks. - private func handlePresenceSignal(surfaceID: UUID, id: String, metadata: String) { - switch Self.presenceEvent( - id: id, - metadata: metadata, - surfaceID: surfaceID, - surfaceExists: surfaces[surfaceID] != nil - ) { - case .success(let event): - onAgentHookEvent?(event) - case .failure(.parseFailed): - // Malformed metadata on a live surface is probe-shaped; warn (mirrors notify). - terminalStateLogger.warning("Dropped malformed OSC presence signal for surface \(surfaceID).") - case .failure(.unknownSurface): - terminalStateLogger.debug("Dropped OSC presence signal for surface \(surfaceID).") - } - } - - /// Typed reasons a presence signal was dropped, so the single call site can pick a - /// log severity per cause (warn for malformed, debug otherwise). - enum PresenceDrop: Error, Equatable { - case unknownSurface - case parseFailed - } - - /// Pure decision for an OSC presence signal: returns an `AgentHookEvent` - /// attributed to the RECEIVING surface when the surface is known and the metadata - /// is well-formed; otherwise a typed `PresenceDrop` so the caller can log per - /// cause. The wire never carries a surface id (so a payload can't spoof another - /// worktree). The parser rejects a non-positive pid before it could reach the - /// liveness sweep; a forged positive pid at worst pins a live-looking badge. - nonisolated static func presenceEvent( - id: String, - metadata: String, - surfaceID: UUID, - surfaceExists: Bool - ) -> Result { - guard surfaceExists else { return .failure(.unknownSurface) } - guard let signal = AgentPresenceOSC.parse(id: id, metadata: metadata) else { - return .failure(.parseFailed) - } - return .success( - AgentHookEvent( - agent: signal.agent, event: signal.eventRawValue, surfaceID: surfaceID, pid: signal.pid)) - } - - /// Parse an OSC 3008 notify signal for the receiving surface, then sanitize and - /// display it. Gated by the rich-notifications setting. - private func handleNotifySignal(surfaceID: UUID, id: String, metadata: String) { - switch Self.notification( - id: id, - metadata: metadata, - surfaceExists: surfaces[surfaceID] != nil - ) { - case .success(let resolved): - // Gate AFTER parse so the setting can't be probed via drop-rate signals. - @Shared(.settingsFile) var settingsFile - guard settingsFile.global.richAgentNotificationsEnabled else { - terminalStateLogger.debug("Dropped OSC notify; rich notifications disabled.") - return - } - // A body present on the wire but decoded empty means a truncation, an - // escape-cut the shed loop couldn't recover, or a non-base64 (probe / forged) - // field: keep it out of silent-failure territory by logging, even though we - // still show the title-only toast. - if resolved.body.isEmpty, resolved.wireBodyByteCount > 0 { - let wireBytes = resolved.wireBodyByteCount - terminalStateLogger.warning( - "OSC notify body present on wire (\(wireBytes) b64 bytes) but decoded empty, dropped: surface \(surfaceID)." - ) - } - appendHookNotification(title: resolved.title, body: resolved.body, surfaceID: surfaceID) - case .failure(.parseFailed): - // parseNotify only fails on a non-notify / empty id (not a truncated body, - // which decodes to an empty field, logged in the success arm above). - terminalStateLogger.warning( - "Dropped malformed OSC notify (metadata bytes: \(metadata.utf8.count)) for surface \(surfaceID).") - case .failure(.unknownSurface), .failure(.empty): - terminalStateLogger.debug("Dropped OSC notify signal for surface \(surfaceID).") - } - } - - /// Typed reasons a notify signal was dropped, so the single call site can pick a - /// log severity per cause (warn for malformed, debug otherwise). - enum NotifyDrop: Error { - case unknownSurface - case parseFailed - case empty - } - - /// A parsed + sanitized notify ready for display, plus the raw wire body byte - /// count so the call site can log a truncated-to-empty body. - struct ResolvedNotification: Equatable { - let title: String - let body: String - let wireBodyByteCount: Int - } - - /// Pure parse decision for an OSC notify signal. Title/body are bounded and - /// stripped of control characters since anything on the terminal can emit one. - /// Title falls back to the agent name; body may be empty. - nonisolated static func notification( - id: String, - metadata: String, - surfaceExists: Bool - ) -> Result { - guard surfaceExists else { return .failure(.unknownSurface) } - guard let notify = AgentPresenceOSC.parseNotify(id: id, metadata: metadata) else { - return .failure(.parseFailed) - } - // Second-line defense behind the emit-side caps (notifyTitleByteBudget / - // notifyBodyByteBudget): these are scalar counts, not bytes, and the wire is - // already bounded, so they only bite on a hand-crafted oversized payload. - let title = sanitizeNotificationText(notify.title ?? notify.agent, max: 200) - let body = sanitizeNotificationText(notify.body ?? "", max: 1000) - guard !(title.isEmpty && body.isEmpty) else { return .failure(.empty) } - return .success(ResolvedNotification(title: title, body: body, wireBodyByteCount: notify.wireBodyByteCount)) - } - - /// Bound length and neutralize control characters in attacker-influenceable - /// notification text. Newline / tab / carriage return collapse to a space; - /// other C0 controls and DEL are dropped (defends against escape-sequence - /// injection into the toast). Length is capped in unicode scalars. - nonisolated static func sanitizeNotificationText(_ text: String, max: Int) -> String { - var scalars = String.UnicodeScalarView() - for scalar in text.unicodeScalars { - if scalars.count >= max { break } - switch scalar.value { - case 0x0A, 0x09, 0x0D: - scalars.append(" ") - case 0x00...0x1F, 0x7F: - continue - default: - scalars.append(scalar) - } - } - return String(scalars).trimmingCharacters(in: .whitespaces) - } - - private struct InheritedSurfaceConfig: Equatable { - let workingDirectory: URL? - let fontSize: Float32? - } - - private func inheritedSurfaceConfig( - fromSurfaceId surfaceID: UUID?, - context: ghostty_surface_context_e - ) -> InheritedSurfaceConfig { - guard let surfaceID, - let view = surfaces[surfaceID], - let sourceSurface = view.surface - else { - return InheritedSurfaceConfig(workingDirectory: nil, fontSize: nil) - } - - let inherited = ghostty_surface_inherited_config(sourceSurface, context) - let fontSize = inherited.font_size == 0 ? nil : inherited.font_size - let workingDirectory = inherited.working_directory.flatMap { ptr -> URL? in - let path = String(cString: ptr) - if path.isEmpty { - return nil - } - return URL(fileURLWithPath: path, isDirectory: true) - } - return InheritedSurfaceConfig(workingDirectory: workingDirectory, fontSize: fontSize) - } - - private static let rememberedZoomFontSizeKey = "terminalRememberedFontSize" - - /// Seed for a sourceless surface, gated on `window-inherit-font-size`. - private var rememberedZoomFontSize: Float32? { - guard runtime.windowInheritsFontSize() else { return nil } - @Shared(.appStorage(Self.rememberedZoomFontSizeKey)) var stored: Double = 0 - return stored > 0 ? Float32(stored) : nil - } - - /// Sample and persist the focused surface's zoom (worktree switch, quit). - func rememberFocusedZoom() { - guard let id = currentFocusedSurfaceId(), let surface = surfaces[id]?.surface else { return } - persistZoomFontSize(ghostty_surface_font_size(surface)) - } - - /// 0 clears a prior zoom, matching Ghostty dropping the override on reset. - private func persistZoomFontSize(_ size: Float32) { - guard runtime.windowInheritsFontSize() else { return } - @Shared(.appStorage(Self.rememberedZoomFontSizeKey)) var stored: Double = 0 - $stored.withLock { $0 = Double(max(size, 0)) } - } - - private func currentFocusedSurfaceId() -> UUID? { - guard let selectedTabId = tabManager.selectedTabId else { return nil } - return focusedSurfaceIdByTab[selectedTabId] - } - - private func updateTabTitle(for tabId: TerminalTabID) { - guard let focusedId = focusedSurfaceIdByTab[tabId], - let surface = surfaces[focusedId], - let title = surface.bridge.state.title - else { return } - tabManager.updateTitle(tabId, title: title) - } - - private func focusSurface(in tabId: TerminalTabID) { - if let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] { - focusSurface(surface, in: tabId) - return - } - let tree = splitTree(for: tabId) - if let leaf = tree.visibleLeaves().first { - focusLeaf(leaf, in: tabId) - } - } - - /// Focuses a split-tree leaf by routing through its content kind. Terminal is - /// the only kind today; a new leaf kind adds a `case` here that the compiler - /// forces. - private func focusLeaf(_ leaf: SurfaceView, in tabId: TerminalTabID) { - switch leaf.content { - case .terminal(let surface): focusSurface(surface, in: tabId) - } - } - - private func focusSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { - let previousSurface = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0] } - recordActiveSurface(surface, in: tabId) - guard tabId == tabManager.selectedTabId else { return } - let fromSurface = (previousSurface === surface) ? nil : previousSurface - GhosttySurfaceView.moveFocus(to: surface, from: fromSurface) - } - - // Single choke point for mutating the "active pane" of a tab. Reached both - // from explicit focus paths (programmatic focus, split navigation, zoom) - // and from AppKit responder changes when the user clicks a pane. - private func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { - setFocusedSurface(surface.id, for: tabId) - markNotificationsRead(forSurfaceID: surface.id) - updateTabTitle(for: tabId) - emitFocusChangedIfNeeded(surface.id) - } - - // Single source of truth for the tab's active pane so the overlay renderer - // can't drift across surfaces. Self-corrects when the stored id points at a - // since-closed surface (or is nil while leaves still exist): a tab with any - // visible leaves must report exactly one of them as active, otherwise the - // dim-overlay reads either "no surface selected" (no leaf matches) or "all - // surfaces selected" (no id → guard short-circuits the dim check for every - // leaf). - func activeSurfaceID(for tabId: TerminalTabID) -> UUID? { - if let stored = focusedSurfaceIdByTab[tabId], surfaces[stored] != nil { - return stored - } - return trees[tabId]?.visibleLeaves().first?.id - } - - /// Appends a notification from a custom (hook / OSC 3008) source. Records the - /// time so the agent's own OSC 9 for the same event is deduped, and cancels any - /// OSC 9 currently held for this surface (the expanded one supersedes it). - func appendHookNotification(title: String, body: String, surfaceID: UUID) { - guard surfaces[surfaceID] != nil else { - terminalStateLogger.debug("Dropped hook notification for unknown surface \(surfaceID) in worktree \(worktree.id)") - return - } - lastCustomNotificationAt[surfaceID] = clock.now - if let superseded = pendingAgentOSCNotifications.removeValue(forKey: surfaceID) { - superseded.cancel() - terminalStateLogger.debug( - "Dropped held agent OSC 9 for surface \(surfaceID) in worktree \(worktree.id): superseded by hook notification" - ) - } - appendNotification(title: title, body: body, surfaceID: surfaceID) - } - - /// The agent's own OSC 9 desktop notification, a summary of the expanded custom - /// notification we ship. Deduped: dropped if a custom notification just - /// committed for this surface (hook-first); otherwise held briefly and dropped - /// if a custom one supersedes it during the hold (OSC-9-first), else shown. - private func handleAgentOSCNotification(title: String, body: String, surfaceID: UUID) { - if let last = lastCustomNotificationAt[surfaceID], - Self.elapsed(from: last, to: clock.now) <= .seconds(Self.oscSuppressionAfterCustom) - { - terminalStateLogger.debug( - "Dropped agent OSC 9 for surface \(surfaceID) in \(worktree.id): custom notification within dedupe window" - ) - return - } - let clock = clock - pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() - pendingAgentOSCNotifications[surfaceID] = Task { [weak self] in - do { - try await clock.sleep(for: .seconds(Self.oscHoldWindow)) - } catch is CancellationError { - return - } catch { - terminalStateLogger.error("OSC 9 hold sleep failed: \(error)") - return - } - guard !Task.isCancelled, let self else { return } - self.pendingAgentOSCNotifications.removeValue(forKey: surfaceID) - guard self.surfaces[surfaceID] != nil else { return } - self.appendNotification(title: title, body: body, surfaceID: surfaceID) - } - } - - private func appendNotification(title: String, body: String, surfaceID: UUID) { - let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) - let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) - guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } - let isViewed = isViewedSurface(surfaceID) - if notificationsEnabled { - notifications.insert( - WorktreeTerminalNotification( - surfaceID: surfaceID, - title: trimmedTitle, - body: trimmedBody, - createdAt: now, - isRead: isViewed - ), - at: 0 - ) - refreshSurfaceUnseenFlag(surfaceID) - if let tabId = tabID(containing: surfaceID) { - emitTabProjection(for: tabId) - } - emitNotificationStateChanged() - } - onNotificationReceived?(surfaceID, trimmedTitle, trimmedBody, isViewed) - } - - /// Detaches one surface from the local bookkeeping. The zmx session is NOT - /// killed here; callers route the kill through `killZmxSessions(forSurfaceIDs:)` - /// so a single multi-pane close emits one `count=N` analytics event + one - /// `withTaskGroup` instead of N events and N detached Tasks. - /// Also cancels any held agent OSC 9 and forgets the last-custom-notification - /// instant so a future surface ID can't reuse stale dedupe state. - private func discardSurfaceBookkeeping(for surfaceID: UUID) { - pendingAgentOSCNotifications.removeValue(forKey: surfaceID)?.cancel() - lastCustomNotificationAt.removeValue(forKey: surfaceID) - surfaces.removeValue(forKey: surfaceID) - surfaceLaunchMetadata.removeValue(forKey: surfaceID) - pendingExplicitSurfaceCloseIDs.remove(surfaceID) - surfaceStates.removeValue(forKey: surfaceID) - } - - private func cleanupSurfaceState(for surfaceID: UUID) { - discardSurfaceBookkeeping(for: surfaceID) - onSurfacesClosed?([surfaceID]) - } - - private func removeTree(for tabId: TerminalTabID) { - guard let tree = trees.removeValue(forKey: tabId) else { return } - surfaceGenerationByTab.removeValue(forKey: tabId) - let leafIDs = tree.leaves().map(\.id) - for leaf in tree.leaves() { - switch leaf.content { - case .terminal(let surface): - surface.closeSurface() - cleanupSurfaceState(for: surface.id) - } - } - terminal.killZmxSessions(forSurfaceIDs: leafIDs, includeRemote: true) - focusedSurfaceIdByTab.removeValue(forKey: tabId) - if lastTabProjections.removeValue(forKey: tabId) != nil { - onTabRemoved?(tabId) - } - } - - func tabID(containing surfaceID: UUID) -> TerminalTabID? { - for (tabId, tree) in trees where tree.find(id: surfaceID) != nil { - return tabId - } - return nil - } - - private func isFocusedSurface(_ surfaceID: UUID) -> Bool { - guard let selectedTabId = tabManager.selectedTabId else { - return false - } - return focusedSurfaceIdByTab[selectedTabId] == surfaceID - } - - private func isViewedSurface(_ surfaceID: UUID) -> Bool { - isSelected() && isFocusedSurface(surfaceID) && isVisibleSurface(surfaceID) - && lastWindowIsKey == true && lastWindowIsVisible == true - } - - // A split-zoomed tab hides every pane outside the zoomed subtree, so a focused - // pane can still be off screen; gate on the zoom-aware visible leaves. - private func isVisibleSurface(_ surfaceID: UUID) -> Bool { - guard let selectedTabId = tabManager.selectedTabId else { return false } - return trees[selectedTabId]?.visibleLeaves().contains { $0.id == surfaceID } == true - } - - /// True for a blocking-script tab whose script has already finished. - func isBlockingScriptCompleted(_ tabId: TerminalTabID) -> Bool { - tabManager.tabs.first(where: { $0.id == tabId })?.isBlockingScriptCompleted == true - } - - private func updateRunningState(for tabId: TerminalTabID) { - guard trees[tabId] != nil else { return } - // Frozen tabs stay sticky: the bridge's stale watch re-fires - // `onProgressReport(REMOVE)` after `command_finished` and would otherwise - // resurrect the dirty shimmer on a tab the user reads as done. - let isFrozen = isBlockingScriptCompleted(tabId) - tabManager.updateDirty(tabId, isDirty: isFrozen ? false : isTabBusy(tabId)) - emitTabProgressDisplay(for: tabId) - emitTaskStatusIfChanged() - } - - /// Compute the per-tab stripe progress payload off `trees[tabId]`'s surfaces. - /// Selected tab → focused-surface state; unselected tab → worst-of-all - /// (ERROR > PAUSE > determinate > indeterminate > none). - private func computeTabProgressDisplay(for tabId: TerminalTabID) -> TerminalTabProgressDisplay? { - guard let tree = trees[tabId] else { return nil } - let leaves = tree.leaves() - if tabManager.selectedTabId == tabId, - let focusedID = focusedSurfaceIdByTab[tabId], - let focused = leaves.first(where: { $0.id == focusedID }) - { - switch focused.content { - case .terminal(let surface): - return TerminalTabProgressDisplay.make( - progressState: surface.bridge.state.progressState, - progressValue: surface.bridge.state.progressValue - ) - } - } - var worst: TerminalTabProgressDisplay? - for leaf in leaves { - switch leaf.content { - case .terminal(let surface): - guard - let candidate = TerminalTabProgressDisplay.make( - progressState: surface.bridge.state.progressState, - progressValue: surface.bridge.state.progressValue - ) - else { continue } - if worst == nil || candidate.severity > worst!.severity { - worst = candidate - } - } - } - return worst - } - - /// Recompute and emit the tab's progress display when it differs from the - /// cached value. Idempotent so OSC-9 ticks that don't move the stripe state - /// don't fire the callback. - private func emitTabProgressDisplay(for tabId: TerminalTabID) { - let newDisplay = computeTabProgressDisplay(for: tabId) - if lastTabProgressDisplays[tabId] != newDisplay { - lastTabProgressDisplays[tabId] = newDisplay - onTabProgressDisplayChanged?(tabId, newDisplay) - } - } - - private func emitTaskStatusIfChanged() { - let newStatus = taskStatus - if newStatus != lastReportedTaskStatus { - lastReportedTaskStatus = newStatus - onTaskStatusChanged?(newStatus) - } - } - - private func emitFocusChangedIfNeeded(_ surfaceID: UUID) { - guard surfaceID != lastEmittedFocusSurfaceId else { return } - lastEmittedFocusSurfaceId = surfaceID - onFocusChanged?(surfaceID) - } - - /// `currentProjection()` already includes the full list and per-item `isRead`, - /// so the sidebar/popover must re-sync on every mutation, not just when - /// `hasUnseenNotification` flips. Gating here broke dismiss / mark-read of - /// already-read notifications (#385). Downstream emits self-dedupe, so keep - /// this ungated. - private func emitNotificationStateChanged() { - onNotificationIndicatorChanged?() - } - - private func syncFocusIfNeeded() { - guard lastWindowIsKey != nil, lastWindowIsVisible != nil else { return } - applySurfaceActivity() - } - - private func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { - setTree(tree, for: tabId) - syncFocusIfNeeded() - } - - /// Single mutation point for `trees[tabId]`. Recomputes and emits the per-tab - /// projection so `TerminalTabFeature.State` mirrors `trees[tabId]`'s leaves - /// + the tab's unread count + focus without observing worktree-wide state. - private func setTree(_ tree: SplitTree, for tabId: TerminalTabID) { - trees[tabId] = tree - // Zoom transitions flip the hide-single-tab-bar gate. - updateShouldHideTabBar() - emitTabProjection(for: tabId) - } - - /// Single mutation point for `focusedSurfaceIdByTab[tabId]`. Mirrors into the - /// per-tab projection so the stripe-progress leaf observes the focus change - /// per-tab instead of through the worktree-wide dictionary. - private func setFocusedSurface(_ surfaceID: UUID?, for tabId: TerminalTabID) { - if let surfaceID { - focusedSurfaceIdByTab[tabId] = surfaceID - } else { - focusedSurfaceIdByTab.removeValue(forKey: tabId) - } - emitTabProjection(for: tabId) - } - - /// Recompute the per-tab projection and emit `onTabProjectionChanged` when - /// the value differs from the cached one. Idempotent: a no-op rebuild - /// (e.g. a notification arrived on a surface that's already counted) does - /// not fire the callback. - private func emitTabProjection(for tabId: TerminalTabID) { - guard let tree = trees[tabId] else { - surfaceGenerationByTab.removeValue(forKey: tabId) - if lastTabProjections.removeValue(forKey: tabId) != nil { - onTabRemoved?(tabId) - } - return - } - let surfaceIDs = tree.leaves().map(\.id) - let surfaceIDSet = Set(surfaceIDs) - let unseenCount = notifications.reduce(into: 0) { partial, notification in - if !notification.isRead, surfaceIDSet.contains(notification.surfaceID) { - partial += 1 - } - } - let projection = WorktreeTabProjection( - tabID: tabId, - surfaceIDs: surfaceIDs, - activeSurfaceID: focusedSurfaceIdByTab[tabId], - unseenNotificationCount: unseenCount, - isSplitZoomed: tree.zoomed != nil, - surfaceGeneration: surfaceGenerationByTab[tabId, default: 0], - ) - guard lastTabProjections[tabId] != projection else { return } - lastTabProjections[tabId] = projection - onTabProjectionChanged?(projection) - } - - /// Recompute every tab's projection. Used after notification-list mutations - /// that may span multiple tabs (mark-all-read, dismiss-all). - private func emitAllTabProjections() { - for tabId in trees.keys { - emitTabProjection(for: tabId) - } - } - - /// Snapshot all current tab projections. Manager replays this on every fresh - /// event-stream subscriber so `terminalTabs[id:]` reconstructs without - /// waiting for the next per-tab mutation. - func currentTabProjections() -> [WorktreeTabProjection] { - Array(lastTabProjections.values) - } - - /// Snapshot all current per-tab stripe-progress displays. Replayed alongside - /// `currentTabProjections()` so the stripe paints the right state on the - /// first frame after re-subscribe. - func currentTabProgressDisplays() -> [TerminalTabID: TerminalTabProgressDisplay?] { - lastTabProgressDisplays - } - - private func isRunningProgressState(_ state: ghostty_action_progress_report_state_e?) -> Bool { - switch state { - case .some(GHOSTTY_PROGRESS_STATE_SET), - .some(GHOSTTY_PROGRESS_STATE_INDETERMINATE), - .some(GHOSTTY_PROGRESS_STATE_PAUSE), - .some(GHOSTTY_PROGRESS_STATE_ERROR): - return true - default: - return false - } - } - - private func mapSplitDirection(_ direction: GhosttySplitAction.NewDirection) - -> SplitTree.NewDirection - { - switch direction { - case .left: - return .left - case .right: - return .right - case .top: - return .top - case .down: - return .down - } - } - - private func mapFocusDirection(_ direction: GhosttySplitAction.FocusDirection) - -> SplitTree.FocusDirection - { - switch direction { - case .previous: - return .previous - case .next: - return .next - case .left: - return .spatial(.left) - case .right: - return .spatial(.right) - case .top: - return .spatial(.top) - case .down: - return .spatial(.down) - } - } - - private func mapResizeDirection(_ direction: GhosttySplitAction.ResizeDirection) - -> SplitTree.SpatialDirection - { - switch direction { - case .left: - return .left - case .right: - return .right - case .top: - return .top - case .down: - return .down - } - } - - private func handleCloseRequest(for view: GhosttySurfaceView) { - guard surfaces[view.id] === view else { return } - let isExplicitClose = pendingExplicitSurfaceCloseIDs.remove(view.id) != nil - if shouldHandleAsUnexpectedZmxClose( - surfaceID: view.id, - isExplicitClose: isExplicitClose - ) { - handleUnexpectedZmxClose(for: view) - return - } - // The host-side session dies only on explicit close: a non-explicit exit - // (e.g. a clean remote exit with the session already gone, a deliberate - // host-side detach, or a reconnect abort) spares it. - closeSurfaceAndUpdateTabs(view, killZmxSession: true, includeRemoteSession: isExplicitClose) - } - - private func shouldHandleAsUnexpectedZmxClose( - surfaceID: UUID, - isExplicitClose: Bool - ) -> Bool { - guard !isExplicitClose else { return false } - return surfaceLaunchMetadata[surfaceID]?.usesZmx == true - } - - private func handleUnexpectedZmxClose(for view: GhosttySurfaceView) { - let surfaceID = view.id - let sessionID = ZmxSessionID.make(surfaceID: surfaceID) - let client = zmxClient - Task { @MainActor [weak self, weak view] in - let sessions = await client.listSessionsWithClients() - guard let self, let view, self.surfaces[surfaceID] === view else { return } - guard let sessions else { - terminalStateLogger.info( - "Closing unexpectedly exited zmx surface \(surfaceID) without killing session: probe failed." - ) - self.closeSurfaceAndUpdateTabs(view, killZmxSession: false) - return - } - guard let session = sessions.first(where: { $0.name == sessionID }) else { - self.closeSurfaceAndUpdateTabs(view, killZmxSession: true) - return - } - // Reattach only an idle session we positively own (0 clients). A session - // with another attached client (clients > 0) or an unknown count (nil) must - // never be destroyed, matching the orphan reaper's spare-on-in-use rule. - guard let clients = session.clients, clients == 0 else { - self.closeSurfaceAndUpdateTabs(view, killZmxSession: false) - return - } - if !self.replaceUnexpectedZmxSurface(view) { - self.closeSurfaceAndUpdateTabs(view, killZmxSession: false) - } - } - } - - @discardableResult - private func replaceUnexpectedZmxSurface(_ view: GhosttySurfaceView) -> Bool { - guard let metadata = surfaceLaunchMetadata[view.id], metadata.usesZmx else { return false } - guard zmxClient.executableURL() != nil else { - terminalStateLogger.info( - "Cannot replace unexpectedly exited zmx surface \(view.id): zmx executable unavailable." - ) - return false - } - guard let tabId = tabID(containing: view.id), let tree = trees[tabId], let node = tree.find(id: view.id) else { - return false - } - let previousState = surfaceStates[view.id] - let replacement = createSurface( - tabId: tabId, - initialInput: nil, - inheritingFromSurfaceId: view.id, - context: metadata.context, - surfaceID: view.id, - bypassZmx: false, - replacingExistingSurfaceID: true, - ) - if let previousState { - surfaceStates[view.id] = previousState - } - surfaceLaunchMetadata[view.id] = metadata - do { - let newTree = try tree.replacing(node: node, with: .leaf(view: replacement)) - view.closeSurface() - bumpSurfaceGeneration(for: tabId) - updateTree(newTree, for: tabId) - updateRunningState(for: tabId) - if focusedSurfaceIdByTab[tabId] == view.id { - focusSurface(replacement, in: tabId) - } - terminalStateLogger.info("Reattached unexpectedly exited zmx surface \(view.id).") - return true - } catch { - terminalStateLogger.warning("Failed to replace unexpectedly exited zmx surface \(view.id): \(error).") - replacement.closeSurface() - discardSurfaceBookkeeping(for: replacement.id) - surfaces[view.id] = view - if let previousState { - surfaceStates[view.id] = previousState - } - surfaceLaunchMetadata[view.id] = metadata - return false - } - } - - private func bumpSurfaceGeneration(for tabId: TerminalTabID) { - surfaceGenerationByTab[tabId, default: 0] += 1 - } - - private func closeSurfaceAndUpdateTabs( - _ view: GhosttySurfaceView, - killZmxSession: Bool, - includeRemoteSession: Bool = false - ) { - guard let tabId = tabID(containing: view.id), let tree = trees[tabId] else { - view.closeSurface() - cleanupSurfaceState(for: view.id) - if killZmxSession { - terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } - return - } - guard let node = tree.find(id: view.id) else { - view.closeSurface() - cleanupSurfaceState(for: view.id) - if killZmxSession { - terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } - return - } - let nextSurface = - focusedSurfaceIdByTab[tabId] == view.id - ? tree.focusTargetAfterClosing(node) - : nil - let newTree = tree.removing(node) - view.closeSurface() - cleanupSurfaceState(for: view.id) - if killZmxSession { - terminal.killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } - if newTree.isEmpty { - trees.removeValue(forKey: tabId) - focusedSurfaceIdByTab.removeValue(forKey: tabId) - terminal.cleanupBlockingScriptLaunchDirectory(for: tabId) - tabManager.closeTab(tabId) - updateShouldHideTabBar() - if let kind = blockingScripts.removeValue(forKey: tabId) { - lastBlockingScriptTabByKind.removeValue(forKey: kind) - - onBlockingScriptCompleted?(kind, nil, nil) - } else { - for (kind, tracked) in lastBlockingScriptTabByKind where tracked == tabId { - lastBlockingScriptTabByKind.removeValue(forKey: kind) - } - } - emitTaskStatusIfChanged() - // Closing the last surface via `close_surface` removes the tab here but - // skips the `closeTab` projection path; emit one so `onTabRemoved` fires - // and the layout persistence sink observes the tab going away. - emitTabProjection(for: tabId) - return - } - updateTree(newTree, for: tabId) - updateRunningState(for: tabId) - if focusedSurfaceIdByTab[tabId] == view.id { - if let nextSurface { - focusLeaf(nextSurface, in: tabId) - } else { - focusedSurfaceIdByTab.removeValue(forKey: tabId) - } - } - // Invariant: a tab with visible leaves must have a live, focused surface so - // AppKit's firstResponder lands on something the user can type into. The - // transfer above only fires when the closed surface was the recorded - // focused one; re-check afterwards and push focus to the first visible - // leaf when the recorded id still doesn't resolve to a live surface. - if focusedSurfaceIdByTab[tabId].flatMap({ surfaces[$0] }) == nil, - let fallback = newTree.visibleLeaves().first - { - focusLeaf(fallback, in: tabId) - } - } - - // Selects the 1-based Nth tab, clamped to the last tab, matching Ghostty's `goto_tab:N`. - func selectTabAtIndex(_ index: Int) { - let tabs = tabManager.tabs - guard index >= 1, !tabs.isEmpty else { return } - selectTab(tabs[min(index - 1, tabs.count - 1)].id) - } - - private func handleGotoTabRequest(_ target: ghostty_action_goto_tab_e) -> Bool { - let tabs = tabManager.tabs - guard !tabs.isEmpty else { return false } - let raw = Int(target.rawValue) - let selectedIndex = tabManager.selectedTabId.flatMap { selected in - tabs.firstIndex { $0.id == selected } - } - let targetIndex: Int - if raw <= 0 { - switch raw { - case Int(GHOSTTY_GOTO_TAB_PREVIOUS.rawValue): - let current = selectedIndex ?? 0 - targetIndex = (current - 1 + tabs.count) % tabs.count - case Int(GHOSTTY_GOTO_TAB_NEXT.rawValue): - let current = selectedIndex ?? 0 - targetIndex = (current + 1) % tabs.count - case Int(GHOSTTY_GOTO_TAB_LAST.rawValue): - targetIndex = tabs.count - 1 - default: - return false - } - } else { - targetIndex = min(raw - 1, tabs.count - 1) - } - selectTab(tabs[targetIndex].id) - return true - } - - private func mapDropZone(_ zone: TerminalSplitTreeView.DropZone) - -> SplitTree.NewDirection - { - switch zone { - case .top: - return .top - case .bottom: - return .down - case .left: - return .left - case .right: - return .right - } - } - - private func nextTabIndex() -> Int { - let prefix = "\(worktree.name) " - var maxIndex = 0 - for tab in tabManager.tabs { - guard tab.title.hasPrefix(prefix) else { continue } - let suffix = tab.title.dropFirst(prefix.count) - guard let value = Int(suffix) else { continue } - maxIndex = max(maxIndex, value) - } - return maxIndex + 1 - } - - #if DEBUG - /// Test-only seam for bulk-assigning the notifications log. Fans - /// `emitAllTabProjections()` so `lastTabProjections` stays in sync with - /// the raw log; production code must go through the per-event helpers - /// (`appendHookNotification`, `markNotificationsRead`, etc.) which already - /// emit. Gated `#if DEBUG` so release builds genuinely can't reach the - /// projection-bypass path. - func setNotificationsForTesting(_ list: [WorktreeTerminalNotification]) { - notifications = list - clearAllSurfaceUnseenFlags() - for surfaceID in Set(list.map(\.surfaceID)) { - refreshSurfaceUnseenFlag(surfaceID) - } - emitAllTabProjections() - } - - /// Test-only seam for installing a synthetic `WorktreeSurfaceState` without - /// minting a real Ghostty surface. Production writes are gated to - /// `createSurface` / `cleanupSurfaceState`. - func installSurfaceStateForTesting(_ state: WorktreeSurfaceState, forSurfaceID surfaceID: UUID) { - surfaceStates[surfaceID] = state - } - - /// Test-only read of the tab's active pane id. - func focusedSurfaceIDForTesting(in tabId: TerminalTabID) -> UUID? { - focusedSurfaceIdByTab[tabId] - } - - #endif -} diff --git a/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift b/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift index 408f9c32b..b0072e763 100644 --- a/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift +++ b/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift @@ -16,7 +16,7 @@ struct TerminalTabFeature { let worktreeID: Worktree.ID /// Surface IDs in this tab in split-tree order. Mirrored from - /// `WorktreeTerminalState`'s `onTabProjectionChanged`. + /// `WorktreeSurfaceState`'s `onTabProjectionChanged`. var surfaceIDs: [UUID] = [] /// Focused pane in this tab. Drives the stripe-progress's per-tab source /// (focused tab → focused surface; non-focused → worst-of aggregate). diff --git a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift index 1b780f5d0..fa1149269 100644 --- a/supacode/Features/Terminal/Reducer/TerminalsFeature.swift +++ b/supacode/Features/Terminal/Reducer/TerminalsFeature.swift @@ -34,7 +34,7 @@ struct TerminalsFeature { enum Action { case terminalTabs(IdentifiedActionOf) - /// Tab projection arrived from `WorktreeTerminalState`. Inserts a new + /// Tab projection arrived from `WorktreeSurfaceState`. Inserts a new /// per-tab state if missing, then forwards to the tab's reducer. case tabProjectionChanged(worktreeID: Worktree.ID, projection: WorktreeTabProjection) /// Tab destroyed in the worktree state. Drops the matching feature state. diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift index 7de3c7fe1..da2daa8cb 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabBarView.swift @@ -4,7 +4,7 @@ import SwiftUI struct TerminalTabBarView: View { @Bindable var manager: TerminalTabManager - let terminalState: WorktreeTerminalState + let terminalState: WorktreeSurfaceState let terminalsStore: StoreOf let createTab: () -> Void let split: (TerminalSplitMenuDirection) -> Void diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift index b526ea542..9b620284a 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsRowView.swift @@ -4,7 +4,7 @@ import SwiftUI struct TerminalTabsRowView: View { @Bindable var manager: TerminalTabManager - let terminalState: WorktreeTerminalState + let terminalState: WorktreeSurfaceState let terminalsStore: StoreOf @Binding var openedTabs: [TerminalTabID] @Binding var tabLocations: [TerminalTabID: CGRect] @@ -41,7 +41,7 @@ struct TerminalTabsRowView: View { fixedWidth: fixedTabWidth, tabStore: tabStore, onSelect: { - // Route through WorktreeTerminalState so selecting a tab also focuses its focused surface. + // Route through WorktreeSurfaceState so selecting a tab also focuses its focused surface. terminalState.selectTab(id) }, onClose: { diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift index 31b0197c0..2aad9c2c1 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabsView.swift @@ -4,7 +4,7 @@ import SwiftUI struct TerminalTabsView: View { @Bindable var manager: TerminalTabManager - let terminalState: WorktreeTerminalState + let terminalState: WorktreeSurfaceState let terminalsStore: StoreOf let closeTab: (TerminalTabID) -> Void let closeOthers: (TerminalTabID) -> Void diff --git a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift index 1e5d7746f..b83e47981 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -3,9 +3,9 @@ import SwiftUI struct TerminalSplitTreeView: View { let tree: SplitTree - // Owns the per-surface `WorktreeSurfaceState` map; leaves resolve their + // Owns the per-surface `SurfaceIndicatorState` map; leaves resolve their // notification flag through `terminalState.surfaceStates[id]`. - let terminalState: WorktreeTerminalState + let terminalState: WorktreeSurfaceState // Single source of truth for which pane is active in this tab. Any surface // whose id does not match this gets the unfocused-split dim overlay. let activeSurfaceID: UUID? @@ -39,7 +39,7 @@ struct TerminalSplitTreeView: View { struct SubtreeView: View { let node: SplitTree.Node var isRoot: Bool = false - let terminalState: WorktreeTerminalState + let terminalState: WorktreeSurfaceState let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (Operation) -> Void @@ -104,7 +104,7 @@ struct TerminalSplitTreeView: View { struct LeafView: View { let surfaceView: GhosttySurfaceView - let surfaceState: WorktreeSurfaceState? + let surfaceState: SurfaceIndicatorState? let isSplit: Bool let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) @@ -186,7 +186,7 @@ struct TerminalSplitTreeView: View { /// on this surface invalidates only this overlay, not the entire split tree. /// Nil while a surface is mid-registration; renders nothing in that window. private struct SurfaceNotificationDotIndicator: View { - let state: WorktreeSurfaceState? + let state: SurfaceIndicatorState? var body: some View { let isShowing = state?.hasUnseenNotification == true @@ -219,7 +219,7 @@ private struct SurfaceNotificationDot: View { /// list of terminal panes to assistive technologies. struct TerminalSplitTreeAXContainer: NSViewRepresentable { let tree: SplitTree - let terminalState: WorktreeTerminalState + let terminalState: WorktreeSurfaceState let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (TerminalSplitTreeView.Operation) -> Void diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 19b36edb3..c7ad81678 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -4,7 +4,7 @@ import SwiftUI struct WorktreeTerminalTabsView: View { let worktree: Worktree - let manager: WorktreeTerminalManager + let manager: WorktreeSurfaceManager /// Narrowed terminal-orchestration store. The tab bar scopes per-tab /// `TerminalTabFeature` stores via `\.terminalTabs[id:]` from here, so the /// tab-bar surface area stays bounded to terminal state. @@ -112,12 +112,12 @@ struct WorktreeTerminalTabsView: View { } /// Reads the per-tab projection so SwiftUI invalidates whenever the tab's surface -/// set or focus changes. `WorktreeTerminalState.trees` and `focusedSurfaceIdByTab` +/// set or focus changes. `WorktreeSurfaceState.trees` and `focusedSurfaceIdByTab` /// are `@ObservationIgnored`, so without this dependency Cmd+D / Cmd+W would not /// re-render until something else (a worktree switch) forced a body recompute. private struct TerminalSplitTreePane: View { let tabId: TerminalTabID - let terminalState: WorktreeTerminalState + let terminalState: WorktreeSurfaceState let terminalsStore: StoreOf let unfocusedSplitOverlay: (fill: Color?, opacity: Double) diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 0253c46b9..6fcb4a419 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -62,7 +62,7 @@ final class GhosttySurfaceBridge { // Fired on OSC 11 background changes only; used to re-tint window chrome // when the focused surface's background changes. var onColorChanged: (() -> Void)? - // Used by blocking script completion detection in WorktreeTerminalState. + // Used by blocking script completion detection in WorktreeSurfaceState. // Both callbacks are set on every surface but guarded by the // blockingScripts dict in the handlers. var onCommandFinished: ((Int?) -> Void)? diff --git a/supacodeTests/AgentBusyStateTests.swift b/supacodeTests/AgentBusyStateTests.swift index b2d81acd4..09e0acfb4 100644 --- a/supacodeTests/AgentBusyStateTests.swift +++ b/supacodeTests/AgentBusyStateTests.swift @@ -57,7 +57,7 @@ struct AgentBusyStateTests { } @Test func multipleSurfacesBusyInDifferentTabs() { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo a"))) @@ -378,9 +378,9 @@ struct AgentBusyStateTests { @MainActor private struct SurfaceFixture { - let manager: WorktreeTerminalManager + let manager: WorktreeSurfaceManager let presence: PresenceTestHarness - let state: WorktreeTerminalState + let state: WorktreeSurfaceState let tabId: TerminalTabID let surface: GhosttySurfaceView @@ -431,7 +431,7 @@ struct AgentBusyStateTests { } private func makeStateWithSurface(worktree: Worktree? = nil) -> SurfaceFixture { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness() let resolvedWorktree = worktree ?? makeWorktree() let state = manager.state(for: resolvedWorktree) { false } @@ -441,9 +441,9 @@ struct AgentBusyStateTests { } private func nextEvent( - _ stream: AsyncStream, - matching predicate: (TerminalClient.Event) -> Bool - ) async -> TerminalClient.Event? { + _ stream: AsyncStream, + matching predicate: (SurfaceClient.Event) -> Bool + ) async -> SurfaceClient.Event? { for await event in stream where predicate(event) { return event } diff --git a/supacodeTests/AgentPresence+TestHelpers.swift b/supacodeTests/AgentPresence+TestHelpers.swift index 10d5e4925..67780abb1 100644 --- a/supacodeTests/AgentPresence+TestHelpers.swift +++ b/supacodeTests/AgentPresence+TestHelpers.swift @@ -13,9 +13,9 @@ import Foundation final class PresenceTestHarness { var state = AgentPresenceFeature.State() private let reducer = AgentPresenceFeature() - private var stream: AsyncStream? + private var stream: AsyncStream? private var consumeTask: Task? - private weak var manager: WorktreeTerminalManager? + private weak var manager: WorktreeSurfaceManager? /// Bumped each time the consume task reduces a stream event. private var processedCount = 0 /// Bumped each time the consume task is about to wait for the next event, i.e. @@ -81,7 +81,7 @@ final class PresenceTestHarness { await clock.advance(by: duration) } - func attach(to manager: WorktreeTerminalManager) { + func attach(to manager: WorktreeSurfaceManager) { self.manager = manager let stream = manager.eventStream() self.stream = stream @@ -109,14 +109,14 @@ final class PresenceTestHarness { } } -extension WorktreeTerminalManager { +extension WorktreeSurfaceManager { @MainActor static func withPresenceHarness( runtime: GhosttyRuntime = GhosttyRuntime(), socketServer: AgentHookSocketServer? = nil, clock: some Clock = ContinuousClock(), - ) -> (manager: WorktreeTerminalManager, presence: PresenceTestHarness) { + ) -> (manager: WorktreeSurfaceManager, presence: PresenceTestHarness) { let harness = PresenceTestHarness() - let manager = WorktreeTerminalManager(runtime: runtime, socketServer: socketServer, clock: clock) + let manager = WorktreeSurfaceManager(runtime: runtime, socketServer: socketServer, clock: clock) harness.attach(to: manager) return (manager, harness) } diff --git a/supacodeTests/AgentPresenceOSCTests.swift b/supacodeTests/AgentPresenceOSCTests.swift index 9e09d771d..9096c27a9 100644 --- a/supacodeTests/AgentPresenceOSCTests.swift +++ b/supacodeTests/AgentPresenceOSCTests.swift @@ -86,7 +86,7 @@ struct AgentPresenceOSCTests { @Test func presenceEventThreadsLocalPid() { let metadata = AgentPresenceOSC.metadata(event: .busy, pidSuffix: ";pid=4242") - let result = WorktreeTerminalState.presenceEvent( + let result = WorktreeSurfaceState.presenceEvent( id: "claude", metadata: metadata, surfaceID: UUID(), surfaceExists: true) #expect((try? result.get())?.pid == 4242) } @@ -103,7 +103,7 @@ struct AgentPresenceOSCTests { @Test func presenceEventAttributesToReceivingSurface() { let surfaceID = UUID() let metadata = AgentPresenceOSC.metadata(event: .busy) - let result = WorktreeTerminalState.presenceEvent( + let result = WorktreeSurfaceState.presenceEvent( id: "claude", metadata: metadata, surfaceID: surfaceID, surfaceExists: true) let event = try? result.get() #expect(event?.surfaceID == surfaceID) @@ -114,13 +114,13 @@ struct AgentPresenceOSCTests { @Test func presenceEventDropsUnknownSurface() { let metadata = AgentPresenceOSC.metadata(event: .busy) - let result = WorktreeTerminalState.presenceEvent( + let result = WorktreeSurfaceState.presenceEvent( id: "claude", metadata: metadata, surfaceID: UUID(), surfaceExists: false) #expect(result == .failure(.unknownSurface)) } @Test func presenceEventDropsMalformedMetadata() { - let result = WorktreeTerminalState.presenceEvent( + let result = WorktreeSurfaceState.presenceEvent( id: "claude", metadata: "event=nope", surfaceID: UUID(), surfaceExists: true) #expect(result == .failure(.parseFailed)) } @@ -239,7 +239,7 @@ struct AgentPresenceOSCTests { // MARK: - notification (parse + sanitize). @Test func notificationExtractsBody() { - let resolved = WorktreeTerminalState.notification( + let resolved = WorktreeSurfaceState.notification( id: "claude", metadata: Self.notifyMeta(body: "all done"), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") @@ -249,7 +249,7 @@ struct AgentPresenceOSCTests { } @Test func notificationDropsUnknownSurface() { - let result = WorktreeTerminalState.notification( + let result = WorktreeSurfaceState.notification( id: "claude", metadata: Self.notifyMeta(body: "x"), surfaceExists: false) if case .failure(.unknownSurface) = result {} else { Issue.record("expected unknownSurface, got \(result)") } } @@ -259,7 +259,7 @@ struct AgentPresenceOSCTests { // routes `.unknownSurface` to `.debug`, never `.warning`. Asserting the exact // failure case locks that mapping in since `parseFailed` is the only // warn-level branch. - let result = WorktreeTerminalState.notification( + let result = WorktreeSurfaceState.notification( id: "claude", metadata: Self.notifyMeta(body: "x"), surfaceExists: false) guard case .failure(let drop) = result else { Issue.record("expected failure, got \(result)") @@ -273,7 +273,7 @@ struct AgentPresenceOSCTests { } @Test func notificationFallsBackToAgentTitleWhenAbsent() { - let resolved = WorktreeTerminalState.notification( + let resolved = WorktreeSurfaceState.notification( id: "codex", metadata: Self.notifyMeta(body: "body only"), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") @@ -284,7 +284,7 @@ struct AgentPresenceOSCTests { @Test func notificationShowsTitleOnlyToastWhenBodyAbsent() { // A turn-complete notify with no body still fires, showing just the title. - let resolved = WorktreeTerminalState.notification( + let resolved = WorktreeSurfaceState.notification( id: "claude", metadata: Self.notifyMeta(), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") @@ -297,19 +297,19 @@ struct AgentPresenceOSCTests { @Test func notificationDropsPayloadThatSanitizesEmpty() { // Body of only control / whitespace and no usable title sanitizes to empty, // so the toast is suppressed rather than shown blank. - let result = WorktreeTerminalState.notification( + let result = WorktreeSurfaceState.notification( id: " ", metadata: Self.notifyMeta(body: "\n"), surfaceExists: true) if case .failure(.empty) = result {} else { Issue.record("expected empty, got \(result)") } } @Test func sanitizeStripsControlCharsAndCollapsesNewlines() { let dirty = "a\u{1B}[31mred\u{07}\nline" - #expect(WorktreeTerminalState.sanitizeNotificationText(dirty, max: 1000) == "a[31mred line") + #expect(WorktreeSurfaceState.sanitizeNotificationText(dirty, max: 1000) == "a[31mred line") } @Test func sanitizeCapsToMaxScalars() { let long = String(repeating: "x", count: 500) - #expect(WorktreeTerminalState.sanitizeNotificationText(long, max: 100).count == 100) + #expect(WorktreeSurfaceState.sanitizeNotificationText(long, max: 100).count == 100) } @Test func notificationStripsEmbeddedOSCSequenceFromBody() { @@ -321,7 +321,7 @@ struct AgentPresenceOSCTests { // must drop both the opening ESC and the trailing ST ESC before the // toast sees them. let body = "before\u{1B}]3008;start=evil;event=busy\u{1B}\\after" - let resolved = WorktreeTerminalState.notification( + let resolved = WorktreeSurfaceState.notification( id: "claude", metadata: Self.notifyMeta(body: body), surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") @@ -336,7 +336,7 @@ struct AgentPresenceOSCTests { // The standalone sanitize entry point pins the same contract directly. let dirty = "before\u{1B}]3008;start=evil;event=busy\u{1B}\\after" #expect( - WorktreeTerminalState.sanitizeNotificationText(dirty, max: 1000) + WorktreeSurfaceState.sanitizeNotificationText(dirty, max: 1000) == #"before]3008;start=evil;event=busy\after"#) } @@ -352,14 +352,14 @@ struct AgentPresenceOSCTests { let signal = AgentPresenceOSC.parseNotify(id: "codex", metadata: metadata) #expect(signal?.body == bodyText) - let resolved = WorktreeTerminalState.notification( + let resolved = WorktreeSurfaceState.notification( id: "codex", metadata: metadata, surfaceExists: true) guard case .success(let value) = resolved else { Issue.record("expected success, got \(resolved)") return } #expect(value.title == "Big") - // Body is sanitized + capped at 1000 scalars (see WorktreeTerminalState.notification). + // Body is sanitized + capped at 1000 scalars (see WorktreeSurfaceState.notification). #expect(value.body.count == 1000) #expect(value.body.allSatisfy { $0 == "y" }) } diff --git a/supacodeTests/AppFeatureArchivedSelectionTests.swift b/supacodeTests/AppFeatureArchivedSelectionTests.swift index 25b8389b8..98b8c096f 100644 --- a/supacodeTests/AppFeatureArchivedSelectionTests.swift +++ b/supacodeTests/AppFeatureArchivedSelectionTests.swift @@ -38,7 +38,7 @@ struct AppFeatureArchivedSelectionTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in } + $0.surfaceClient.send = { _ in } $0.worktreeInfoWatcher.send = { _ in } } @@ -98,11 +98,11 @@ struct AppFeatureArchivedSelectionTests { .init(id: scriptID, tint: activeTint) appState.repositories.sidebarItems[id: archivedWorktree.id]?.runningScripts[id: scriptID] = .init(id: scriptID, tint: archivedTint) - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let sentCommands = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore(initialState: appState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sentCommands.withValue { $0.append(command) } } $0.worktreeInfoWatcher.send = { _ in } @@ -132,11 +132,11 @@ struct AppFeatureArchivedSelectionTests { repositories: repositoriesState, settings: SettingsFeature.State() ) - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let sentCommands = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore(initialState: appState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sentCommands.withValue { $0.append(command) } } $0.worktreeInfoWatcher.send = { _ in } @@ -165,11 +165,11 @@ struct AppFeatureArchivedSelectionTests { repositories: repositoriesState, settings: SettingsFeature.State() ) - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let sentCommands = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore(initialState: appState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sentCommands.withValue { $0.append(command) } } $0.worktreeInfoWatcher.send = { _ in } @@ -218,12 +218,12 @@ struct AppFeatureArchivedSelectionTests { appState.agentPresence.records[ AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) ] = AgentPresenceFeature.PresenceRecord(activity: .awaitingInput, pids: [42]) - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let sentCommands = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore(initialState: appState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sentCommands.withValue { $0.append(command) } } $0.worktreeInfoWatcher.send = { _ in } @@ -291,22 +291,22 @@ struct AppFeatureArchivedSelectionTests { appState.agentPresence.records[ AgentPresenceFeature.PresenceKey(agent: .claude, surfaceID: surfaceID) ] = AgentPresenceFeature.PresenceRecord(activity: .busy, pids: [42]) - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let sentCommands = LockIsolated<[SurfaceClient.Command]>([]) let clock = TestClock() let store = TestStore(initialState: appState) { AppFeature() } withDependencies: { $0.continuousClock = clock - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sentCommands.withValue { $0.append(command) } } - $0.terminalClient.saveLayoutsWithAgents = { _ in } + $0.surfaceClient.saveLayoutsWithAgents = { _ in } } store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .worktreeProjectionChanged( worktree.id, WorktreeRowProjection( @@ -349,7 +349,7 @@ struct AppFeatureArchivedSelectionTests { AppFeature() } withDependencies: { $0.continuousClock = clock - $0.terminalClient.saveLayoutsWithAgents = { agents in + $0.surfaceClient.saveLayoutsWithAgents = { agents in savedAgents.setValue(agents) } } @@ -372,17 +372,17 @@ struct AppFeatureArchivedSelectionTests { settings: SettingsFeature.State() ) appState.agentPresence.bySurface[surfaceID] = [.claude] - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let sentCommands = LockIsolated<[SurfaceClient.Command]>([]) let clock = TestClock() let store = TestStore(initialState: appState) { AppFeature() } withDependencies: { $0.continuousClock = clock - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sentCommands.withValue { $0.append(command) } } - $0.terminalClient.saveLayoutsWithAgents = { _ in } + $0.surfaceClient.saveLayoutsWithAgents = { _ in } } store.exhaustivity = .off @@ -401,17 +401,17 @@ struct AppFeatureArchivedSelectionTests { repositories: RepositoriesFeature.State(), settings: SettingsFeature.State() ) - let sentCommands = LockIsolated<[TerminalClient.Command]>([]) + let sentCommands = LockIsolated<[SurfaceClient.Command]>([]) let clock = TestClock() let store = TestStore(initialState: appState) { AppFeature() } withDependencies: { $0.continuousClock = clock - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sentCommands.withValue { $0.append(command) } } - $0.terminalClient.saveLayoutsWithAgents = { _ in } + $0.surfaceClient.saveLayoutsWithAgents = { _ in } } store.exhaustivity = .off @@ -439,7 +439,7 @@ struct AppFeatureArchivedSelectionTests { AppFeature() } withDependencies: { $0.continuousClock = clock - $0.terminalClient.saveLayoutsWithAgents = { _ in + $0.surfaceClient.saveLayoutsWithAgents = { _ in writeCount.withValue { $0 += 1 } } } diff --git a/supacodeTests/AppFeatureCommandAckTests.swift b/supacodeTests/AppFeatureCommandAckTests.swift index db2d464e6..1700b9bcf 100644 --- a/supacodeTests/AppFeatureCommandAckTests.swift +++ b/supacodeTests/AppFeatureCommandAckTests.swift @@ -51,7 +51,7 @@ struct AppFeatureCommandAckTests { #expect(store.state.pendingCommandAcks[id: writeFD] != nil) await store.send( - .terminalEvent( + .surfaceEvent( .tabProjectionChanged( worktreeID: worktree.id, WorktreeTabProjection( @@ -149,7 +149,7 @@ struct AppFeatureCommandAckTests { let store = TestStore(initialState: initial) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in } + $0.surfaceClient.send = { _ in } } store.exhaustivity = .off @@ -160,7 +160,7 @@ struct AppFeatureCommandAckTests { .createRandomWorktreeSucceeded(created, repositoryID: "/tmp/repo", pendingID: pendingID))) #expect(store.state.pendingCommandAcks[id: writeFD] != nil) - await store.send(.terminalEvent(.tabCreated(worktreeID: created.id))) + await store.send(.surfaceEvent(.tabCreated(worktreeID: created.id))) await store.finish() #expect(store.state.pendingCommandAcks.isEmpty) @@ -330,7 +330,7 @@ struct AppFeatureCommandAckTests { #expect(store.state.pendingCommandAcks[id: writeFD] != nil) await store.send( - .terminalEvent(.tabRemoved(worktreeID: worktree.id, tabID: TerminalTabID(rawValue: tabID))) + .surfaceEvent(.tabRemoved(worktreeID: worktree.id, tabID: TerminalTabID(rawValue: tabID))) ) await store.finish() @@ -348,7 +348,7 @@ struct AppFeatureCommandAckTests { // save so `store.finish()` isn't left with an in-flight effect. let store = makeStore(worktree: worktree, tabExists: true) { $0.continuousClock = ImmediateClock() - $0.terminalClient.saveLayoutsWithAgents = { _ in } + $0.surfaceClient.saveLayoutsWithAgents = { _ in } } let (readFD, writeFD) = makePipe() defer { close(readFD) } @@ -363,7 +363,7 @@ struct AppFeatureCommandAckTests { ) #expect(store.state.pendingCommandAcks[id: writeFD] != nil) - await store.send(.terminalEvent(.surfacesClosed(worktreeID: worktree.id, [surfaceID]))) + await store.send(.surfaceEvent(.surfacesClosed(worktreeID: worktree.id, [surfaceID]))) await store.finish() #expect(store.state.pendingCommandAcks.isEmpty) @@ -467,7 +467,7 @@ struct AppFeatureCommandAckTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in } + $0.surfaceClient.send = { _ in } } store.exhaustivity = .off @@ -540,7 +540,7 @@ struct AppFeatureCommandAckTests { defer { close(store.readFD) } await store.store.send( - .terminalEvent( + .surfaceEvent( .surfaceCreationFailed( worktreeID: worktree.id, attemptedID: surfaceID, message: "Could not create the split surface." ))) @@ -609,10 +609,10 @@ struct AppFeatureCommandAckTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.tabExists = { _, _ in tabExists } - $0.terminalClient.surfaceExists = { _, _, _ in tabExists } - $0.terminalClient.surfaceExistsInWorktree = { _, _ in tabExists } - $0.terminalClient.send = { _ in } + $0.surfaceClient.tabExists = { _, _ in tabExists } + $0.surfaceClient.surfaceExists = { _, _, _ in tabExists } + $0.surfaceClient.surfaceExistsInWorktree = { _, _ in tabExists } + $0.surfaceClient.send = { _ in } extraDependencies(&$0) } store.exhaustivity = .off diff --git a/supacodeTests/AppFeatureCommandPaletteTests.swift b/supacodeTests/AppFeatureCommandPaletteTests.swift index a9e74662e..515c00bbe 100644 --- a/supacodeTests/AppFeatureCommandPaletteTests.swift +++ b/supacodeTests/AppFeatureCommandPaletteTests.swift @@ -87,7 +87,7 @@ struct AppFeatureCommandPaletteTests { await store.receive(\.updates.checkForUpdates) } - @Test(.dependencies) func ghosttyCommandDispatchesBindingActionToTerminalClient() async { + @Test(.dependencies) func ghosttyCommandDispatchesBindingActionToSurfaceClient() async { let worktree = makeWorktree( id: "/tmp/repo-ghostty/wt-1", name: "wt-1", @@ -98,7 +98,7 @@ struct AppFeatureCommandPaletteTests { repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) let surfaceID = UUID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -107,10 +107,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedSurfaceID = { _ in surfaceID } + $0.surfaceClient.selectedSurfaceID = { _ in surfaceID } } await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) @@ -133,7 +133,7 @@ struct AppFeatureCommandPaletteTests { var repositoriesState = RepositoriesFeature.State() repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -142,10 +142,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedSurfaceID = { _ in nil } + $0.surfaceClient.selectedSurfaceID = { _ in nil } } await store.send(.commandPalette(.delegate(.ghosttyCommand("goto_split:right")))) @@ -167,7 +167,7 @@ struct AppFeatureCommandPaletteTests { let firstSurface = UUID() let secondSurface = UUID() let currentSurface = LockIsolated(firstSurface) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -176,10 +176,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedSurfaceID = { _ in currentSurface.value } + $0.surfaceClient.selectedSurfaceID = { _ in currentSurface.value } } let task = await store.send(.commandPalette(.delegate(.ghosttyCommand("toggle_split_zoom")))) @@ -206,7 +206,7 @@ struct AppFeatureCommandPaletteTests { repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) let capturedTabID = TerminalTabID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -215,10 +215,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedTabID = { _ in capturedTabID } + $0.surfaceClient.selectedTabID = { _ in capturedTabID } } await store.send(.commandPalette(.delegate(.ghosttyCommand("prompt_surface_title")))) @@ -238,7 +238,7 @@ struct AppFeatureCommandPaletteTests { repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) let capturedTabID = TerminalTabID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -247,10 +247,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedTabID = { _ in capturedTabID } + $0.surfaceClient.selectedTabID = { _ in capturedTabID } } await store.send(.commandPalette(.delegate(.ghosttyCommand("prompt_tab_title")))) @@ -272,7 +272,7 @@ struct AppFeatureCommandPaletteTests { let firstTabID = TerminalTabID() let secondTabID = TerminalTabID() let currentTabID = LockIsolated(firstTabID) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -281,10 +281,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedTabID = { _ in currentTabID.value } + $0.surfaceClient.selectedTabID = { _ in currentTabID.value } } let task = await store.send(.commandPalette(.delegate(.ghosttyCommand("prompt_surface_title")))) @@ -306,7 +306,7 @@ struct AppFeatureCommandPaletteTests { var repositoriesState = RepositoriesFeature.State() repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -315,10 +315,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedTabID = { _ in nil } + $0.surfaceClient.selectedTabID = { _ in nil } } await store.send(.commandPalette(.delegate(.ghosttyCommand("prompt_surface_title")))) @@ -337,7 +337,7 @@ struct AppFeatureCommandPaletteTests { var repositoriesState = RepositoriesFeature.State() repositoriesState.repositories = [repository] repositoriesState.selection = .worktree(worktree.id) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -346,10 +346,10 @@ struct AppFeatureCommandPaletteTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.selectedSurfaceID = { _ in nil } + $0.surfaceClient.selectedSurfaceID = { _ in nil } } await store.send(.commandPalette(.delegate(.ghosttyCommand("new_split:right")))) @@ -575,7 +575,7 @@ struct AppFeatureCommandPaletteTests { store.exhaustivity = .off // Ghostty's toggle opens the command palette, never the last-used switcher. - await store.send(.terminalEvent(.commandPaletteToggleRequested(worktreeID: worktree.id))) + await store.send(.surfaceEvent(.commandPaletteToggleRequested(worktreeID: worktree.id))) await store.receive(\.commandPalette.presentInMode) #expect(store.state.commandPalette.mode == .commands) #expect(store.state.commandPalette.isPresented == true) diff --git a/supacodeTests/AppFeatureDeeplinkTests.swift b/supacodeTests/AppFeatureDeeplinkTests.swift index 42e734f1c..18bc4fdbb 100644 --- a/supacodeTests/AppFeatureDeeplinkTests.swift +++ b/supacodeTests/AppFeatureDeeplinkTests.swift @@ -425,7 +425,7 @@ struct AppFeatureDeeplinkTests { @Shared(.repositorySettings(rootURL)) var persisted = .default $persisted.withLock { $0.scripts = [definition] } defer { $persisted.withLock { $0.scripts = [] } } - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var settings = SettingsFeature.State() settings.automatedActionPolicy = .always let store = TestStore( @@ -436,7 +436,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -475,13 +475,13 @@ struct AppFeatureDeeplinkTests { repositories.reconcileSidebarForTesting() repositories.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State(repositories: repositories, settings: SettingsFeature.State()) ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -514,7 +514,7 @@ struct AppFeatureDeeplinkTests { defer { $settingsFile.withLock { $0.global.globalScripts = [] } } var settings = SettingsFeature.State() settings.automatedActionPolicy = .always - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -523,7 +523,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -552,13 +552,13 @@ struct AppFeatureDeeplinkTests { repositories.reconcileSidebarForTesting() repositories.sidebarItems[id: worktree.id]?.runningScripts[id: globalScript.id] = .init(id: globalScript.id, tint: globalScript.resolvedTintColor) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State(repositories: repositories, settings: SettingsFeature.State()) ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -589,7 +589,7 @@ struct AppFeatureDeeplinkTests { defer { $settingsFile.withLock { $0.global.globalScripts = [] } } var settings = SettingsFeature.State() settings.automatedActionPolicy = .always - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -598,7 +598,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -625,7 +625,7 @@ struct AppFeatureDeeplinkTests { @Shared(.repositorySettings(rootURL)) var persisted = .default $persisted.withLock { $0.scripts = [definition] } defer { $persisted.withLock { $0.scripts = [] } } - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -634,7 +634,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -658,7 +658,7 @@ struct AppFeatureDeeplinkTests { defer { $persisted.withLock { $0.scripts = [] } } var settings = SettingsFeature.State() settings.automatedActionPolicy = .always - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -667,7 +667,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -695,13 +695,13 @@ struct AppFeatureDeeplinkTests { .init(id: definition.id, tint: definition.resolvedTintColor) var settings = SettingsFeature.State() settings.automatedActionPolicy = .always - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State(repositories: repositories, settings: settings) ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -723,7 +723,7 @@ struct AppFeatureDeeplinkTests { @Shared(.repositorySettings(rootURL)) var persisted = .default $persisted.withLock { $0.scripts = [definition] } defer { $persisted.withLock { $0.scripts = [] } } - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State() @@ -738,7 +738,7 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -875,7 +875,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func tabDestroySkipsConfirmationWhenSettingEnabled() async { let worktree = makeWorktree() let tabUUID = UUID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var settings = SettingsFeature.State() settings.automatedActionPolicy = .always let store = TestStore( @@ -886,10 +886,10 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.tabExists = { _, _ in true } + $0.surfaceClient.tabExists = { _, _ in true } } store.exhaustivity = .off @@ -917,7 +917,7 @@ struct AppFeatureDeeplinkTests { let worktree = makeWorktree() let tabUUID = UUID() let surfaceUUID = UUID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var settings = SettingsFeature.State() settings.automatedActionPolicy = .always let store = TestStore( @@ -928,11 +928,11 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in true } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in true } } store.exhaustivity = .off @@ -980,7 +980,7 @@ struct AppFeatureDeeplinkTests { let worktree = makeWorktree() let tabUUID = UUID() let surfaceUUID = UUID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -989,11 +989,11 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in true } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in true } } store.exhaustivity = .off @@ -1015,7 +1015,7 @@ struct AppFeatureDeeplinkTests { let worktree = makeWorktree() let tabUUID = UUID() let surfaceUUID = UUID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State(), @@ -1031,11 +1031,11 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in true } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in true } } store.exhaustivity = .off @@ -1210,7 +1210,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func worktreeTabWithValidTabID() async { let worktree = makeWorktree() let tabUUID = UUID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -1219,16 +1219,16 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.tabExists = { _, _ in true } + $0.surfaceClient.tabExists = { _, _ in true } } store.exhaustivity = .off await store.send(.deeplink(.worktree(id: worktree.id, action: .tab(tabID: tabUUID)))) await store.receive(\.repositories.selectWorktree) - let expected = TerminalClient.Command.selectTab(worktree, tabID: TerminalTabID(rawValue: tabUUID)) + let expected = SurfaceClient.Command.selectTab(worktree, tabID: TerminalTabID(rawValue: tabUUID)) #expect(sent.value.contains(expected)) } @@ -1246,7 +1246,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func tabNewWithInputSkipsConfirmationWhenSettingEnabled() async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var settings = SettingsFeature.State() settings.automatedActionPolicy = .always let store = TestStore( @@ -1257,7 +1257,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -1274,7 +1274,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func tabNewConfirmationAcceptedSendsTerminalCommand() async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State(), @@ -1283,7 +1283,7 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -1318,7 +1318,7 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in } + $0.surfaceClient.send = { _ in } } store.exhaustivity = .off @@ -1342,7 +1342,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func tabNewConfirmationCancelledDoesNothing() async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), settings: SettingsFeature.State(), @@ -1351,7 +1351,7 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -1366,7 +1366,7 @@ struct AppFeatureDeeplinkTests { } @Test(.dependencies) func tabNewConfirmationWithDeletedWorktreeDoesNothing() async { - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State( repositories: RepositoriesFeature.State(), settings: SettingsFeature.State(), @@ -1380,7 +1380,7 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -1403,7 +1403,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func tabNewWithoutInputCreatesNewTerminal() async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -1412,7 +1412,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -1746,7 +1746,7 @@ struct AppFeatureDeeplinkTests { let worktree = makeWorktree() let tabUUID = UUID() let surfaceUUID = UUID() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -1755,11 +1755,11 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in true } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in true } } store.exhaustivity = .off @@ -1787,7 +1787,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.tabExists = { _, _ in false } + $0.surfaceClient.tabExists = { _, _ in false } } store.exhaustivity = .off @@ -1807,8 +1807,8 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in false } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in false } } store.exhaustivity = .off @@ -1830,8 +1830,8 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in false } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in false } } store.exhaustivity = .off @@ -2032,7 +2032,7 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in } + $0.surfaceClient.send = { _ in } } store.exhaustivity = .off @@ -2055,7 +2055,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func cliOnlyPolicyBypassesConfirmationForSocket() async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var settings = SettingsFeature.State() settings.automatedActionPolicy = .cliOnly let store = TestStore( @@ -2066,7 +2066,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -2177,8 +2177,8 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in true } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in true } } store.exhaustivity = .off return store @@ -2229,7 +2229,7 @@ struct AppFeatureDeeplinkTests { AppFeature() } withDependencies: { $0.appLifecycleClient.terminate = { terminated.setValue(true) } - $0.terminalClient.hasInflightBlockingScripts = { false } + $0.surfaceClient.hasInflightBlockingScripts = { false } } store.exhaustivity = .off @@ -2252,7 +2252,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.hasInflightBlockingScripts = { false } + $0.surfaceClient.hasInflightBlockingScripts = { false } } store.exhaustivity = .off @@ -2272,7 +2272,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.hasInflightBlockingScripts = { true } + $0.surfaceClient.hasInflightBlockingScripts = { true } } store.exhaustivity = .off @@ -2295,7 +2295,7 @@ struct AppFeatureDeeplinkTests { AppFeature() } withDependencies: { $0.appLifecycleClient.terminate = { terminated.setValue(true) } - $0.terminalClient.terminateAllSessions = { terminateSessionsCalled.setValue(true) } + $0.surfaceClient.terminateAllSessions = { terminateSessionsCalled.setValue(true) } } store.exhaustivity = .off @@ -2359,7 +2359,7 @@ struct AppFeatureDeeplinkTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.hasInflightBlockingScripts = { false } + $0.surfaceClient.hasInflightBlockingScripts = { false } } store.exhaustivity = .off @@ -2392,7 +2392,7 @@ struct AppFeatureDeeplinkTests { } withDependencies: { $0.appLifecycleClient.terminate = { terminated.setValue(true) } // Active work, but `.never` shouldn't even consult it. - $0.terminalClient.hasInflightBlockingScripts = { true } + $0.surfaceClient.hasInflightBlockingScripts = { true } } store.exhaustivity = .off @@ -2418,7 +2418,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.hasInflightBlockingScripts = { false } + $0.surfaceClient.hasInflightBlockingScripts = { false } } store.exhaustivity = .off @@ -2441,7 +2441,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.hasInflightBlockingScripts = { false } + $0.surfaceClient.hasInflightBlockingScripts = { false } } store.exhaustivity = .off @@ -2462,7 +2462,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.terminateAllSessions = { terminateCalled.setValue(true) } + $0.surfaceClient.terminateAllSessions = { terminateCalled.setValue(true) } } store.exhaustivity = .off @@ -2480,7 +2480,7 @@ struct AppFeatureDeeplinkTests { @Test(.dependencies) func deeplinksOnlyPolicyBypassesConfirmationForURLScheme() async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var settings = SettingsFeature.State() settings.automatedActionPolicy = .deeplinksOnly let store = TestStore( @@ -2491,7 +2491,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -2573,7 +2573,7 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.tabExists = { _, tabID in tabID.rawValue == existingTabID } + $0.surfaceClient.tabExists = { _, tabID in tabID.rawValue == existingTabID } } store.exhaustivity = .off @@ -2595,9 +2595,9 @@ struct AppFeatureDeeplinkTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.tabExists = { _, _ in true } - $0.terminalClient.surfaceExists = { _, _, _ in true } - $0.terminalClient.surfaceExistsInWorktree = { _, sID in sID == existingSurfaceID } + $0.surfaceClient.tabExists = { _, _ in true } + $0.surfaceClient.surfaceExists = { _, _, _ in true } + $0.surfaceClient.surfaceExistsInWorktree = { _, sID in sID == existingSurfaceID } } store.exhaustivity = .off diff --git a/supacodeTests/AppFeatureDefaultEditorTests.swift b/supacodeTests/AppFeatureDefaultEditorTests.swift index 8c5d8efbf..0453815ec 100644 --- a/supacodeTests/AppFeatureDefaultEditorTests.swift +++ b/supacodeTests/AppFeatureDefaultEditorTests.swift @@ -126,7 +126,7 @@ struct AppFeatureDefaultEditorTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in } + $0.surfaceClient.send = { _ in } $0.worktreeInfoWatcher.send = { command in watcherCommands.withValue { $0.append(command) } } diff --git a/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift index 9cbaff39f..0525ade70 100644 --- a/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift +++ b/supacodeTests/AppFeatureJumpToLatestUnreadTests.swift @@ -11,10 +11,10 @@ import Testing struct AppFeatureJumpToLatestUnreadTests { @Test(.dependencies) func noOpWhenNoUnreadNotifications() async { let worktree = makeWorktree() - let focused = LockIsolated<[TerminalClient.Command]>([]) + let focused = LockIsolated<[SurfaceClient.Command]>([]) let store = makeStore(worktree: worktree) { - $0.terminalClient.latestUnreadNotification = { nil } - $0.terminalClient.send = { command in + $0.surfaceClient.latestUnreadNotification = { nil } + $0.surfaceClient.send = { command in focused.withValue { $0.append(command) } } } @@ -30,10 +30,10 @@ struct AppFeatureJumpToLatestUnreadTests { let tabUUID = UUID() let surfaceUUID = UUID() let notificationUUID = UUID() - let focused = LockIsolated<[TerminalClient.Command]>([]) + let focused = LockIsolated<[SurfaceClient.Command]>([]) let marked = LockIsolated<[(Worktree.ID, UUID)]>([]) let store = makeStore(worktree: worktree) { - $0.terminalClient.latestUnreadNotification = { + $0.surfaceClient.latestUnreadNotification = { NotificationLocation( worktreeID: worktree.id, tabID: TerminalTabID(rawValue: tabUUID), @@ -41,10 +41,10 @@ struct AppFeatureJumpToLatestUnreadTests { notificationID: notificationUUID, ) } - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in focused.withValue { $0.append(command) } } - $0.terminalClient.markNotificationRead = { worktreeID, notificationID in + $0.surfaceClient.markNotificationRead = { worktreeID, notificationID in marked.withValue { $0.append((worktreeID, notificationID)) } } } @@ -53,7 +53,7 @@ struct AppFeatureJumpToLatestUnreadTests { await store.receive(\.repositories.selectWorktree) await store.finish() - let expectedFocus = TerminalClient.Command.focusSurface( + let expectedFocus = SurfaceClient.Command.focusSurface( worktree, tabID: TerminalTabID(rawValue: tabUUID), surfaceID: surfaceUUID, @@ -76,9 +76,9 @@ struct AppFeatureJumpToLatestUnreadTests { @Test(.dependencies) func dropsJumpWhenTargetWorktreeMissing() async { let worktree = makeWorktree() let missingID = "/tmp/repo/does-not-exist" - let focused = LockIsolated<[TerminalClient.Command]>([]) + let focused = LockIsolated<[SurfaceClient.Command]>([]) let store = makeStore(worktree: worktree) { - $0.terminalClient.latestUnreadNotification = { + $0.surfaceClient.latestUnreadNotification = { NotificationLocation( worktreeID: WorktreeID(missingID), tabID: TerminalTabID(rawValue: UUID()), @@ -86,7 +86,7 @@ struct AppFeatureJumpToLatestUnreadTests { notificationID: UUID(), ) } - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in focused.withValue { $0.append(command) } } } @@ -135,8 +135,8 @@ struct AppFeatureJumpToLatestUnreadTests { ) { AppFeature() } withDependencies: { values in - values.terminalClient.tabExists = { _, _ in true } - values.terminalClient.surfaceExists = { _, _, _ in true } + values.surfaceClient.tabExists = { _, _ in true } + values.surfaceClient.surfaceExists = { _, _, _ in true } withAdditionalDependencies(&values) } store.exhaustivity = .off diff --git a/supacodeTests/AppFeatureOpenWorktreeTests.swift b/supacodeTests/AppFeatureOpenWorktreeTests.swift index 988b7f745..7698436f4 100644 --- a/supacodeTests/AppFeatureOpenWorktreeTests.swift +++ b/supacodeTests/AppFeatureOpenWorktreeTests.swift @@ -274,7 +274,7 @@ struct AppFeatureOpenWorktreeTests { private struct TestContext { let worktree: Worktree let openedActions: LockIsolated<[OpenWorktreeAction]> - let terminalCommands: LockIsolated<[TerminalClient.Command]> + let terminalCommands: LockIsolated<[SurfaceClient.Command]> let capturedEvents: LockIsolated<[CapturedEvent]> } @@ -288,7 +288,7 @@ struct AppFeatureOpenWorktreeTests { repositoriesState.reconcileSidebarForTesting() mutate(&repositoriesState, worktree) let openedActions = LockIsolated<[OpenWorktreeAction]>([]) - let terminalCommands = LockIsolated<[TerminalClient.Command]>([]) + let terminalCommands = LockIsolated<[SurfaceClient.Command]>([]) let capturedEvents = LockIsolated<[CapturedEvent]>([]) let storage = SettingsTestStorage() let settingsFileURL = URL( @@ -307,7 +307,7 @@ struct AppFeatureOpenWorktreeTests { $0.workspaceClient.open = { action, _, _ in openedActions.withValue { $0.append(action) } } - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in terminalCommands.withValue { $0.append(command) } } $0.analyticsClient.capture = { event, properties in diff --git a/supacodeTests/AppFeatureRunScriptTests.swift b/supacodeTests/AppFeatureRunScriptTests.swift index b4d6d370a..e39032b5e 100644 --- a/supacodeTests/AppFeatureRunScriptTests.swift +++ b/supacodeTests/AppFeatureRunScriptTests.swift @@ -37,7 +37,7 @@ struct AppFeatureRunScriptTests { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) let definition = ScriptDefinition(kind: .run, name: "Dev", command: "npm run dev") - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State( repositories: repositories, settings: SettingsFeature.State() @@ -46,7 +46,7 @@ struct AppFeatureRunScriptTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -89,7 +89,7 @@ struct AppFeatureRunScriptTests { } await store.send( - .terminalEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: [definition]))) + .surfaceEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: [definition]))) ) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.hasTerminalProjection = true @@ -118,7 +118,7 @@ struct AppFeatureRunScriptTests { } await store.send( - .terminalEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: []))) + .surfaceEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: []))) ) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.hasTerminalProjection = true @@ -140,11 +140,11 @@ struct AppFeatureRunScriptTests { initialState.repositories.sidebarItems[id: worktree.id]?.runningScripts[id: definition.id] = .init(id: definition.id, tint: definition.resolvedTintColor) initialState.repositories.applyPostReduceCacheRecomputes() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -177,7 +177,7 @@ struct AppFeatureRunScriptTests { } await store.send( - .terminalEvent( + .surfaceEvent( .terminal( .blockingScriptCompleted( worktreeID: worktree.id, @@ -210,7 +210,7 @@ struct AppFeatureRunScriptTests { } await store.send( - .terminalEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: [definition]))) + .surfaceEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: [definition]))) ) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.hasTerminalProjection = true @@ -239,7 +239,7 @@ struct AppFeatureRunScriptTests { } await store.send( - .terminalEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: [definition]))) + .surfaceEvent(.worktreeProjectionChanged(worktree.id, makeProjection(scripts: [definition]))) ) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.hasTerminalProjection = true @@ -249,10 +249,10 @@ struct AppFeatureRunScriptTests { } } - @Test(.dependencies) func stopRunScriptsCallsTerminalClient() async { + @Test(.dependencies) func stopRunScriptsCallsSurfaceClient() async { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositories, @@ -261,7 +261,7 @@ struct AppFeatureRunScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -281,7 +281,7 @@ struct AppFeatureRunScriptTests { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositories, @@ -290,7 +290,7 @@ struct AppFeatureRunScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -378,7 +378,7 @@ struct AppFeatureRunScriptTests { let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) let globalScript = ScriptDefinition(kind: .custom, name: "Format", command: "swift-format") - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State( repositories: repositories, settings: SettingsFeature.State() @@ -387,7 +387,7 @@ struct AppFeatureRunScriptTests { let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -469,14 +469,14 @@ struct AppFeatureRunScriptTests { let collidingGlobal = ScriptDefinition(id: sharedID, kind: .custom, name: "Global", command: "echo global") let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State(repositories: repositories, settings: SettingsFeature.State()) initialState.repoScripts = [repoScript] initialState.globalScripts = [collidingGlobal] let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -501,14 +501,14 @@ struct AppFeatureRunScriptTests { let collidingGlobal = ScriptDefinition(id: sharedID, kind: .custom, name: "Global", command: "echo global") let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State(repositories: repositories, settings: SettingsFeature.State()) initialState.repoScripts = [repoScript] initialState.globalScripts = [collidingGlobal] let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -530,14 +530,14 @@ struct AppFeatureRunScriptTests { let orphan = ScriptDefinition(kind: .custom, name: "Stale", command: "echo stale") let worktree = makeWorktree() let repositories = makeRepositoriesState(worktree: worktree) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) var initialState = AppFeature.State(repositories: repositories, settings: SettingsFeature.State()) initialState.repoScripts = [] initialState.globalScripts = [] let store = TestStore(initialState: initialState) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } diff --git a/supacodeTests/AppFeatureSelectTerminalTabTests.swift b/supacodeTests/AppFeatureSelectTerminalTabTests.swift index c6e819094..59869a994 100644 --- a/supacodeTests/AppFeatureSelectTerminalTabTests.swift +++ b/supacodeTests/AppFeatureSelectTerminalTabTests.swift @@ -11,7 +11,7 @@ struct AppFeatureSelectTerminalTabTests { @Test(.dependencies, arguments: [1, 3, 9]) func selectTerminalTabForwardsIndexToSelectedWorktree(tabNumber: Int) async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -20,7 +20,7 @@ struct AppFeatureSelectTerminalTabTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -39,8 +39,8 @@ struct AppFeatureSelectTerminalTabTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in - Issue.record("terminalClient.send should not be called without a selected worktree") + $0.surfaceClient.send = { _ in + Issue.record("surfaceClient.send should not be called without a selected worktree") } } diff --git a/supacodeTests/AppFeatureSplitTerminalTests.swift b/supacodeTests/AppFeatureSplitTerminalTests.swift index 67d496ce6..7275ef5aa 100644 --- a/supacodeTests/AppFeatureSplitTerminalTests.swift +++ b/supacodeTests/AppFeatureSplitTerminalTests.swift @@ -23,7 +23,7 @@ struct AppFeatureSplitTerminalTests { @Test(.dependencies, arguments: TerminalSplitMenuDirection.allCases) func splitTerminalForwardsGhosttyBinding(direction: TerminalSplitMenuDirection) async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -32,7 +32,7 @@ struct AppFeatureSplitTerminalTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -51,8 +51,8 @@ struct AppFeatureSplitTerminalTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { _ in - Issue.record("terminalClient.send should not be called without a selected worktree") + $0.surfaceClient.send = { _ in + Issue.record("surfaceClient.send should not be called without a selected worktree") } } diff --git a/supacodeTests/AppFeatureSystemNotificationTests.swift b/supacodeTests/AppFeatureSystemNotificationTests.swift index 9c419e50b..26b912d0d 100644 --- a/supacodeTests/AppFeatureSystemNotificationTests.swift +++ b/supacodeTests/AppFeatureSystemNotificationTests.swift @@ -131,12 +131,12 @@ struct AppFeatureSystemNotificationTests { $0.systemNotificationClient.send = { title, body, _ in sends.withValue { $0.append((title, body)) } } - $0.terminalClient.tabID = { _, _ in nil } + $0.surfaceClient.tabID = { _, _ in nil } } store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -167,12 +167,12 @@ struct AppFeatureSystemNotificationTests { $0.systemNotificationClient.send = { _, _, _ in sends.withValue { $0 += 1 } } - $0.terminalClient.tabID = { _, _ in nil } + $0.surfaceClient.tabID = { _, _ in nil } } store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -204,12 +204,12 @@ struct AppFeatureSystemNotificationTests { $0.systemNotificationClient.send = { _, _, _ in sends.withValue { $0 += 1 } } - $0.terminalClient.tabID = { _, _ in nil } + $0.surfaceClient.tabID = { _, _ in nil } } store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -244,7 +244,7 @@ struct AppFeatureSystemNotificationTests { store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -278,7 +278,7 @@ struct AppFeatureSystemNotificationTests { store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -314,12 +314,12 @@ struct AppFeatureSystemNotificationTests { $0.notificationSoundClient.play = { _ in plays.withValue { $0 += 1 } } - $0.terminalClient.tabID = { _, _ in nil } + $0.surfaceClient.tabID = { _, _ in nil } } store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -350,12 +350,12 @@ struct AppFeatureSystemNotificationTests { plays.withValue { $0 += 1 } } $0.systemNotificationClient.send = { _, _, _ in } - $0.terminalClient.tabID = { _, _ in nil } + $0.surfaceClient.tabID = { _, _ in nil } } store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -393,7 +393,7 @@ struct AppFeatureSystemNotificationTests { store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), @@ -432,7 +432,7 @@ struct AppFeatureSystemNotificationTests { store.exhaustivity = .off await store.send( - .terminalEvent( + .surfaceEvent( .notificationReceived( worktreeID: "/tmp/repo/wt-1", surfaceID: UUID(), diff --git a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift index c63fce006..207ed936b 100644 --- a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift +++ b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift @@ -16,7 +16,7 @@ struct AppFeatureTerminalSetupScriptTests { pendingSetupScript: true, selected: true ) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -25,13 +25,13 @@ struct AppFeatureTerminalSetupScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } await store.send(.newTerminal) - await store.send(.terminalEvent(.terminal(.setupScriptConsumed(worktreeID: worktree.id)))) + await store.send(.surfaceEvent(.terminal(.setupScriptConsumed(worktreeID: worktree.id)))) await store.receive(\.repositories.consumeSetupScript) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.lifecycle = .idle @@ -48,7 +48,7 @@ struct AppFeatureTerminalSetupScriptTests { pendingSetupScript: false, selected: true ) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -57,7 +57,7 @@ struct AppFeatureTerminalSetupScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -83,7 +83,7 @@ struct AppFeatureTerminalSetupScriptTests { AppFeature() } - await store.send(.terminalEvent(.tabCreated(worktreeID: worktree.id))) + await store.send(.surfaceEvent(.tabCreated(worktreeID: worktree.id))) #expect(store.state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending) await store.finish() } @@ -104,7 +104,7 @@ struct AppFeatureTerminalSetupScriptTests { AppFeature() } - await store.send(.terminalEvent(.terminal(.setupScriptConsumed(worktreeID: worktree.id)))) + await store.send(.surfaceEvent(.terminal(.setupScriptConsumed(worktreeID: worktree.id)))) await store.receive(\.repositories.consumeSetupScript) await store.receive(\.repositories.sidebarItems) { $0.repositories.sidebarItems[id: worktree.id]?.lifecycle = .idle @@ -120,7 +120,7 @@ struct AppFeatureTerminalSetupScriptTests { pendingSetupScript: true, selected: false ) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -129,7 +129,7 @@ struct AppFeatureTerminalSetupScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -150,7 +150,7 @@ struct AppFeatureTerminalSetupScriptTests { pendingSetupScript: false, selected: false ) - let sent = LockIsolated<[TerminalClient.Command]>([]) + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: repositoriesState, @@ -159,7 +159,7 @@ struct AppFeatureTerminalSetupScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } diff --git a/supacodeTests/RepositoriesFeatureSidebarTests.swift b/supacodeTests/RepositoriesFeatureSidebarTests.swift index b09408032..e819154c0 100644 --- a/supacodeTests/RepositoriesFeatureSidebarTests.swift +++ b/supacodeTests/RepositoriesFeatureSidebarTests.swift @@ -460,7 +460,7 @@ struct RepositoriesFeatureSidebarTests { RepositoriesFeature.syncSidebar(&state) // Simulate the empty projection that arrives when the user closes every // tab: surfaceIDs goes to [] but the row has now been claimed by the live - // `WorktreeTerminalState` (`hasTerminalProjection == true`). + // `WorktreeSurfaceState` (`hasTerminalProjection == true`). state.sidebarItems[id: worktreeID]?.surfaceIDs = [] state.sidebarItems[id: worktreeID]?.hasTerminalProjection = true state.pendingAgentRehydrateSurfaces.removeAll() diff --git a/supacodeTests/SplitTreeTests.swift b/supacodeTests/SplitTreeTests.swift index 5bee3288b..1a4922869 100644 --- a/supacodeTests/SplitTreeTests.swift +++ b/supacodeTests/SplitTreeTests.swift @@ -140,7 +140,7 @@ struct SplitTreeTests { $settingsFile.withLock { $0.global.hideSingleTabBar = true } defer { $settingsFile.withLock { $0.global.hideSingleTabBar = originalHide } } - let state = WorktreeTerminalState( + let state = WorktreeSurfaceState( runtime: GhosttyRuntime(), worktree: Worktree( id: "/tmp/repo/wt-zoom", @@ -210,7 +210,7 @@ struct SplitTreeTests { } private func makeWorktreeFixture(preserveZoomOnNavigation: Bool) -> WorktreeFixture { - let state = WorktreeTerminalState( + let state = WorktreeSurfaceState( runtime: GhosttyRuntime(), worktree: makeWorktree(), splitPreserveZoomOnNavigation: { preserveZoomOnNavigation } @@ -239,7 +239,7 @@ struct SplitTreeTests { } private struct WorktreeFixture { - let state: WorktreeTerminalState + let state: WorktreeSurfaceState let tabId: TerminalTabID let first: GhosttySurfaceView let second: GhosttySurfaceView? diff --git a/supacodeTests/TerminalRenderingPolicyTests.swift b/supacodeTests/TerminalRenderingPolicyTests.swift index 06357bc28..d1ed378f8 100644 --- a/supacodeTests/TerminalRenderingPolicyTests.swift +++ b/supacodeTests/TerminalRenderingPolicyTests.swift @@ -7,7 +7,7 @@ import Testing struct TerminalRenderingPolicyTests { @Test func surfaceActivityForSelectedVisibleFocusedSurfaceIsFocused() { let focusedID = UUID() - let activity = WorktreeTerminalState.surfaceActivity( + let activity = WorktreeSurfaceState.surfaceActivity( isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: true, @@ -20,7 +20,7 @@ struct TerminalRenderingPolicyTests { } @Test func surfaceActivityForSelectedVisibleUnfocusedSurfaceIsNotFocused() { - let activity = WorktreeTerminalState.surfaceActivity( + let activity = WorktreeSurfaceState.surfaceActivity( isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: true, @@ -34,7 +34,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForSelectedTabInBackgroundWindowIsVisibleButNotFocused() { let surfaceID = UUID() - let activity = WorktreeTerminalState.surfaceActivity( + let activity = WorktreeSurfaceState.surfaceActivity( isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: true, @@ -48,7 +48,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForOccludedWindowIsHiddenAndUnfocused() { let surfaceID = UUID() - let activity = WorktreeTerminalState.surfaceActivity( + let activity = WorktreeSurfaceState.surfaceActivity( isSurfaceVisibleInTree: true, isSelectedTab: true, windowIsVisible: false, @@ -62,7 +62,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForUnselectedTabIsHiddenAndUnfocused() { let surfaceID = UUID() - let activity = WorktreeTerminalState.surfaceActivity( + let activity = WorktreeSurfaceState.surfaceActivity( isSurfaceVisibleInTree: true, isSelectedTab: false, windowIsVisible: true, @@ -76,7 +76,7 @@ struct TerminalRenderingPolicyTests { @Test func surfaceActivityForZoomHiddenSurfaceIsHiddenAndUnfocused() { let surfaceID = UUID() - let activity = WorktreeTerminalState.surfaceActivity( + let activity = WorktreeSurfaceState.surfaceActivity( isSurfaceVisibleInTree: false, isSelectedTab: true, windowIsVisible: true, diff --git a/supacodeTests/WindowTitleTests.swift b/supacodeTests/WindowTitleTests.swift index ac9416bd9..265fc63e5 100644 --- a/supacodeTests/WindowTitleTests.swift +++ b/supacodeTests/WindowTitleTests.swift @@ -47,40 +47,40 @@ struct WindowTitleTests { @Test func computeReturnsAppNameWhenNoSelection() { let state = RepositoriesFeature.State() - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "Supacode") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "Supacode") } @Test func computeReturnsArchiveLabelForArchivedSelection() { var state = RepositoriesFeature.State() state.selection = .archivedWorktrees - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "Archive") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "Archive") } @Test func computeFallsBackToAppNameForUnknownWorktreeID() { var state = RepositoriesFeature.State() state.selection = .worktree("does-not-exist") - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "Supacode") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "Supacode") } @Test func computeUsesRepositoryNameWhenNoCustomTitle() { let state = makeState(repoName: "acme-app", customTitle: nil) - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "acme-app") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "acme-app") } @Test func computePrefersCustomTitleOverRepositoryName() { let state = makeState(repoName: "acme-app", customTitle: "Acme") - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "Acme") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "Acme") } @Test func computeIgnoresWhitespaceOnlyCustomTitle() { let state = makeState(repoName: "acme-app", customTitle: " ") - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "acme-app") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "acme-app") } @Test func computeFailedRepositoryUsesDirectoryName() { @@ -89,8 +89,8 @@ struct WindowTitleTests { state.repositoryRoots = [URL(fileURLWithPath: id.rawValue)] state.loadFailuresByID = [id: "Not found"] state.selection = .failedRepository(id) - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "missing-repo · Unavailable") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "missing-repo · Unavailable") } @Test func computeFailedRepositoryPrefersCustomTitle() { @@ -104,8 +104,8 @@ struct WindowTitleTests { section.title = "My Project" sidebar.sections[id] = section } - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "My Project · Unavailable") + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "My Project · Unavailable") } @Test func computeFailedRemoteRepositoryUsesPlaceholderNameNotFileURL() { @@ -128,9 +128,9 @@ struct WindowTitleTests { state.repositories = [placeholder] state.loadFailuresByID = [id: "Can't reach devbox."] state.selection = .failedRepository(id) - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) // Deriving from the `user@host` authority id as a file URL would mangle the name. - #expect(WindowTitle.compute(repositories: state, terminalManager: manager) == "proj · Unavailable") + #expect(WindowTitle.compute(repositories: state, surfaceManager: manager) == "proj · Unavailable") } // MARK: - helpers. diff --git a/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift b/supacodeTests/WorktreeSurfaceManagerLayoutPersistenceTests.swift similarity index 98% rename from supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift rename to supacodeTests/WorktreeSurfaceManagerLayoutPersistenceTests.swift index 8590ccea4..e32ac7321 100644 --- a/supacodeTests/WorktreeTerminalManagerLayoutPersistenceTests.swift +++ b/supacodeTests/WorktreeSurfaceManagerLayoutPersistenceTests.swift @@ -13,7 +13,7 @@ struct LayoutPersistenceManagerTests { /// the app performs, so a test can assert both coalescing and final on-disk /// state from one storage. private struct Harness { - let manager: WorktreeTerminalManager + let manager: WorktreeSurfaceManager let clock: TestClock let saveCount: LockIsolated let storage: SettingsFileStorage @@ -56,7 +56,7 @@ struct LayoutPersistenceManagerTests { let manager = withDependencies { $0.settingsFileStorage = storage } operation: { - WorktreeTerminalManager(runtime: GhosttyRuntime(), clock: clock) + WorktreeSurfaceManager(runtime: GhosttyRuntime(), clock: clock) } // Mirror the app's in-memory dict mutation; the writer's storage is the // on-disk source of truth these tests assert against. @@ -268,7 +268,7 @@ struct LayoutPersistenceManagerTests { let manager = withDependencies { $0.settingsFileStorage = storage } operation: { - WorktreeTerminalManager(runtime: GhosttyRuntime(), clock: clock) + WorktreeSurfaceManager(runtime: GhosttyRuntime(), clock: clock) } manager.saveLayoutSnapshot = { _, _ in } diff --git a/supacodeTests/WorktreeTerminalManagerReaperTests.swift b/supacodeTests/WorktreeSurfaceManagerReaperTests.swift similarity index 94% rename from supacodeTests/WorktreeTerminalManagerReaperTests.swift rename to supacodeTests/WorktreeSurfaceManagerReaperTests.swift index b7534cab9..7c1e373f2 100644 --- a/supacodeTests/WorktreeTerminalManagerReaperTests.swift +++ b/supacodeTests/WorktreeSurfaceManagerReaperTests.swift @@ -5,13 +5,13 @@ import Testing @testable import supacode @MainActor -struct WorktreeTerminalManagerReaperTests { +struct WorktreeSurfaceManagerReaperTests { /// Builds a manager whose injected zmx client records every kill and serves /// the supplied `ls` listing (nil = probe failed). private func makeManager( listing: [ZmxSessionListParser.Entry]?, killed: LockIsolated<[String]> - ) -> WorktreeTerminalManager { + ) -> WorktreeSurfaceManager { withDependencies { $0.zmxClient = ZmxClient( executableURL: { nil }, @@ -21,7 +21,7 @@ struct WorktreeTerminalManagerReaperTests { listSessionsWithClients: { listing } ) } operation: { - WorktreeTerminalManager(runtime: GhosttyRuntime()) + WorktreeSurfaceManager(runtime: GhosttyRuntime()) } } @@ -81,7 +81,7 @@ struct WorktreeTerminalManagerReaperTests { listSessionsWithClients: { listing.value } ) } operation: { - WorktreeTerminalManager(runtime: GhosttyRuntime()) + WorktreeSurfaceManager(runtime: GhosttyRuntime()) } let worktree = makeWorktree() diff --git a/supacodeTests/WorktreeTerminalManagerTests.swift b/supacodeTests/WorktreeSurfaceManagerTests.swift similarity index 93% rename from supacodeTests/WorktreeTerminalManagerTests.swift rename to supacodeTests/WorktreeSurfaceManagerTests.swift index cd96d4b04..733cce641 100644 --- a/supacodeTests/WorktreeTerminalManagerTests.swift +++ b/supacodeTests/WorktreeSurfaceManagerTests.swift @@ -13,9 +13,9 @@ import Testing // processes whose event-driven waits flake when interleaved with each other. @MainActor @Suite(.serialized) -struct WorktreeTerminalManagerTests { +struct WorktreeSurfaceManagerTests { @Test func reusesExistingStateAndReloadsSnapshotAfterRestoreIsEnabled() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let snapshot = makeLayoutSnapshot() var restoreEnabled = false @@ -36,7 +36,7 @@ struct WorktreeTerminalManagerTests { } @Test func reusingExistingStateDoesNotReloadSnapshotWhenSetupScriptBecomesPending() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let snapshot = makeLayoutSnapshot() var restoreEnabled = false @@ -58,7 +58,7 @@ struct WorktreeTerminalManagerTests { } @Test func ensureInitialTabCreatesTabSynchronously() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -70,7 +70,7 @@ struct WorktreeTerminalManagerTests { } @Test func ensureInitialTabAfterCloseAllDoesNotAutoRecreate() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -84,7 +84,7 @@ struct WorktreeTerminalManagerTests { } @Test func ensureInitialTabConsumesPendingSnapshotAndStickies() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) state.pendingLayoutSnapshot = makeLayoutSnapshot() @@ -97,7 +97,7 @@ struct WorktreeTerminalManagerTests { } @Test func buffersEventsUntilStreamCreated() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -115,7 +115,7 @@ struct WorktreeTerminalManagerTests { } @Test func emitsEventsAfterStreamCreated() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -139,7 +139,7 @@ struct WorktreeTerminalManagerTests { let server = AgentHookSocketServer(socketPathOverride: "/tmp/supacode-tests/\(UUID().uuidString)") server.shutdown() - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), socketServer: server) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime(), socketServer: server) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -149,7 +149,7 @@ struct WorktreeTerminalManagerTests { @Test func oscHookActivityEventRoutesToWorktreeState() async { let server = AgentHookSocketServer(socketPathOverride: "/tmp/supacode-tests/\(UUID().uuidString)") - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server) + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness(socketServer: server) let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -172,7 +172,7 @@ struct WorktreeTerminalManagerTests { @Test func oscIdleEventIsDebouncedAcrossToolStorm() async { let clock = TestClock() let server = AgentHookSocketServer(socketPathOverride: "/tmp/supacode-tests/\(UUID().uuidString)") - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -203,7 +203,7 @@ struct WorktreeTerminalManagerTests { @Test func oscIdleCommitsAfterDebounceWindow() async { let clock = TestClock() let server = AgentHookSocketServer(socketPathOverride: "/tmp/supacode-tests/\(UUID().uuidString)") - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -231,7 +231,7 @@ struct WorktreeTerminalManagerTests { @Test func oscIdleDebouncesPerAgentIndependently() async { let clock = TestClock() let server = AgentHookSocketServer(socketPathOverride: "/tmp/supacode-tests/\(UUID().uuidString)") - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -264,7 +264,7 @@ struct WorktreeTerminalManagerTests { @Test func oscSessionEndCancelsPendingIdle() async { let clock = TestClock() let server = AgentHookSocketServer(socketPathOverride: "/tmp/supacode-tests/\(UUID().uuidString)") - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -292,7 +292,7 @@ struct WorktreeTerminalManagerTests { @Test func oscSurfaceClosedWhileIdlePendingIsHarmless() async { let clock = TestClock() let server = AgentHookSocketServer(socketPathOverride: "/tmp/supacode-tests/\(UUID().uuidString)") - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness(socketServer: server, clock: clock) + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness(socketServer: server, clock: clock) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -319,7 +319,7 @@ struct WorktreeTerminalManagerTests { } @Test func rowProjectionCarriesRunningScriptsFromBlockingScripts() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") @@ -344,7 +344,7 @@ struct WorktreeTerminalManagerTests { } @Test func runningScriptsMutationsCoalesceIntoOneCallbackPerTurn() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let first = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") let second = ScriptDefinition(kind: .test, name: "Test", command: "echo ok") @@ -375,7 +375,7 @@ struct WorktreeTerminalManagerTests { @Test func runBlockingScriptIgnoresDuplicateOfActiveScript() async { // A second run racing the projection reconcile must keep the running // instance, not close and relaunch its tab (#573). - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") let state = manager.state(for: worktree) @@ -396,7 +396,7 @@ struct WorktreeTerminalManagerTests { // The ignored-duplicate path must still reconcile the row: if the original // running projection was shed, only a fresh emit un-sticks the dropdown from // Run. Without the emit the second continuation would never resume (#573). - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") let state = manager.state(for: worktree) @@ -420,7 +420,7 @@ struct WorktreeTerminalManagerTests { // re-deliver the running projection past the dedupe so the row heals; a // plain deduped emit would suppress the identical value and the second // await would hang (#573). - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") _ = manager.state(for: worktree) @@ -441,7 +441,7 @@ struct WorktreeTerminalManagerTests { @Test func lifecycleScriptRerunReplacesTab() { // The duplicate guard is scoped to user scripts; lifecycle kinds keep their // replace-on-rerun semantics, so a second archive run opens a fresh tab. - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -455,7 +455,7 @@ struct WorktreeTerminalManagerTests { @Test func runningScriptsFlowThroughRowProjectionEvents() async { // Pins the wiring the dropdown fix hangs on: `blockingScripts` mutations // reach TCA as `worktreeProjectionChanged` events carrying the set (#573). - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, name: "Dev", command: "echo ok") let stream = manager.eventStream() @@ -474,7 +474,7 @@ struct WorktreeTerminalManagerTests { @Test func stopWithoutTrackedScriptForcesProjectionReEmit() async { // A stop that matches nothing means the caller acted on a stale mirror; // the forced re-emit is what lets a phantom Stop click self-heal (#573). - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() _ = manager.state(for: worktree) let stream = manager.eventStream() @@ -490,7 +490,7 @@ struct WorktreeTerminalManagerTests { @Test func shedProjectionInvalidatesDedupeSoNextEmitLands() async { // A shed projection was never delivered, so its dedupe entries must not // suppress the next identical emit or the row strands desynced (#573). - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), eventBufferCap: 1) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime(), eventBufferCap: 1) let worktree = makeWorktree() let state = manager.state(for: worktree) // Relies on the projection being the last subscribe-time seed, so it is @@ -511,7 +511,7 @@ struct WorktreeTerminalManagerTests { // A shed projection with no later terminal mutation must still redeliver, or // a completed script's clear transition strands the Run/Stop dropdown (#573). // Without the replay this await hangs: nothing else emits the row. - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), eventBufferCap: 1) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime(), eventBufferCap: 1) let worktree = makeWorktree() let state = manager.state(for: worktree) let stream = manager.eventStream() @@ -528,7 +528,7 @@ struct WorktreeTerminalManagerTests { @Test func shedNotificationIndicatorInvalidatesItsCountGate() async { // The indicator has its own check-before-emit cache; a shed event must // reset it or the dock count strands until the count actually changes. - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime(), eventBufferCap: 2) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime(), eventBufferCap: 2) let worktree = makeWorktree() // Subscribe before any state exists so the buffer holds only the // subscribe-time indicator event. @@ -553,7 +553,7 @@ struct WorktreeTerminalManagerTests { } @Test func runBlockingScriptReportsFailureWhenLaunchCannotBeBuilt() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let definition = ScriptDefinition(kind: .run, name: "Dev", command: " ") // Local: an unbuildable launch reports completion instead of a silent nil, @@ -577,7 +577,7 @@ struct WorktreeTerminalManagerTests { } private func nextRunningScripts( - _ stream: AsyncStream + _ stream: AsyncStream ) async -> IdentifiedArrayOf? { for await event in stream { if case .worktreeProjectionChanged(_, let projection) = event { @@ -588,7 +588,7 @@ struct WorktreeTerminalManagerTests { } @Test func stopScriptWithoutTerminalStateDoesNotMintOne() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .stopScript(definitionID: UUID()))) @@ -602,7 +602,7 @@ struct WorktreeTerminalManagerTests { $0.date.now = Date(timeIntervalSince1970: 1_234) $0.continuousClock = ImmediateClock() } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree(id: "/tmp/repo/wt with spaces") manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -628,7 +628,7 @@ struct WorktreeTerminalManagerTests { } @Test func notificationIndicatorUsesCurrentCountOnStreamStart() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -666,7 +666,7 @@ struct WorktreeTerminalManagerTests { } @Test func presenceHasActivityReflectsAnyBusySurface() { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -703,7 +703,7 @@ struct WorktreeTerminalManagerTests { } @Test func hasUnseenNotificationsReflectsUnreadEntries() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -720,7 +720,7 @@ struct WorktreeTerminalManagerTests { } @Test func markAllNotificationsReadEmitsUpdatedIndicatorCount() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -744,7 +744,7 @@ struct WorktreeTerminalManagerTests { } @Test func markNotificationsReadOnlyAffectsMatchingSurface() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceA = UUID() @@ -771,7 +771,7 @@ struct WorktreeTerminalManagerTests { } @Test func setNotificationsDisabledMarksAllRead() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -787,7 +787,7 @@ struct WorktreeTerminalManagerTests { } @Test func dismissAllNotificationsClearsState() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -808,7 +808,7 @@ struct WorktreeTerminalManagerTests { // projection refresh and the bell stayed showing them. Dismiss must signal // unconditionally so the sidebar row's `notifications` array (which the bell // group-existence check reads) clears. - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let state = manager.state(for: makeWorktree()) state.setNotificationsForTesting([makeNotification(isRead: true), makeNotification(isRead: true)]) var indicatorEmits = 0 @@ -821,7 +821,7 @@ struct WorktreeTerminalManagerTests { } @Test func dismissReadNotificationRefreshesRow() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let state = manager.state(for: makeWorktree()) let read = makeNotification(isRead: true) state.setNotificationsForTesting([read]) @@ -837,7 +837,7 @@ struct WorktreeTerminalManagerTests { // MARK: - Per-surface unseen flag @Test func setNotificationsForTestingHydratesPerSurfaceFlag() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceA = UUID() @@ -855,7 +855,7 @@ struct WorktreeTerminalManagerTests { } @Test func markNotificationsReadFlipsOnlyMatchingSurfaceFlag() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceA = UUID() @@ -874,7 +874,7 @@ struct WorktreeTerminalManagerTests { } @Test func markSingleNotificationReadKeepsFlagWhenOlderUnreadRemains() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceID = UUID() @@ -893,7 +893,7 @@ struct WorktreeTerminalManagerTests { } @Test func dismissAllNotificationsClearsPerSurfaceFlag() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceID = UUID() @@ -909,7 +909,7 @@ struct WorktreeTerminalManagerTests { } @Test func dismissSingleNotificationRefreshesPerSurfaceFlag() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceID = UUID() @@ -930,7 +930,7 @@ struct WorktreeTerminalManagerTests { $0.date.now = Date(timeIntervalSince1970: 1_234) $0.continuousClock = ImmediateClock() } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), @@ -951,7 +951,7 @@ struct WorktreeTerminalManagerTests { $0.date.now = Date(timeIntervalSince1970: 1_234) $0.continuousClock = ImmediateClock() } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) state.isSelected = { true } @@ -970,7 +970,7 @@ struct WorktreeTerminalManagerTests { } @Test func createSurfaceInstallsSurfaceStateEntry() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), @@ -1000,7 +1000,7 @@ struct WorktreeTerminalManagerTests { } private func restoreSurface(agents: [TerminalLayoutSnapshot.SurfaceAgentRecord]) -> GhosttySurfaceView? { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let surfaceID = UUID() @@ -1040,7 +1040,7 @@ struct WorktreeTerminalManagerTests { } @Test func cleanupSurfaceStateRemovesSurfaceStateEntry() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), @@ -1062,7 +1062,7 @@ struct WorktreeTerminalManagerTests { $0.date.now = Date(timeIntervalSince1970: 1_234) $0.continuousClock = ImmediateClock() } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabId = state.createTab(focusing: false), @@ -1250,7 +1250,7 @@ struct WorktreeTerminalManagerTests { let manager = makeZmxBackedManager(probe: probe, worktree: worktree) let state = manager.state(for: worktree) guard let tabID = state.createTab(focusing: true), - let surface = state.splitTree(for: tabID).root?.leftmostLeaf() + let surface = state.splitTree(for: tabID).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a tab and surface") return @@ -1311,7 +1311,7 @@ struct WorktreeTerminalManagerTests { listSessionsWithClients: { [] } ) } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) _ = manager.state(for: worktree) return manager } @@ -1357,7 +1357,7 @@ struct WorktreeTerminalManagerTests { let devbox = RemoteHost(alias: "devbox") let other = RemoteHost(alias: "other") - let plan = WorktreeTerminalManager.killPlan( + let plan = WorktreeSurfaceManager.killPlan( localSessionIDs: ["local-only", "both", "local-only"], remoteSessions: [ (host: devbox, sessionID: "both"), @@ -1791,7 +1791,7 @@ struct WorktreeTerminalManagerTests { } @Test func restoreLayoutSnapshotReDerivesPerSurfaceFlags() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let knownSurfaceID = UUID() let snapshot = TerminalLayoutSnapshot( @@ -1832,7 +1832,7 @@ struct WorktreeTerminalManagerTests { $0.date.now = Date(timeIntervalSince1970: 1_234) $0.continuousClock = ImmediateClock() } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) state.notificationsEnabled = false @@ -1849,19 +1849,19 @@ struct WorktreeTerminalManagerTests { } } - /// Installs a fresh `WorktreeSurfaceState` via the DEBUG-gated test seam. + /// Installs a fresh `SurfaceIndicatorState` via the DEBUG-gated test seam. @discardableResult private func installSurfaceState( - on state: WorktreeTerminalState, + on state: WorktreeSurfaceState, forSurfaceID surfaceID: UUID - ) -> WorktreeSurfaceState { - let surfaceState = WorktreeSurfaceState() + ) -> SurfaceIndicatorState { + let surfaceState = SurfaceIndicatorState() state.installSurfaceStateForTesting(surfaceState, forSurfaceID: surfaceID) return surfaceState } @Test func blockingScriptCompletionReportsExitCodeFromCommandFinished() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() @@ -1889,7 +1889,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptCompletionPassesNilExitCodeWhenCommandFinishedReportsNil() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() @@ -1918,7 +1918,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptCommandFinishedFollowedByChildExitDoesNotDoubleFire() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() @@ -1951,7 +1951,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptChildExitWithoutCommandFinishedIsCancellation() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() @@ -1982,7 +1982,7 @@ struct WorktreeTerminalManagerTests { // A remote surface's child is ssh itself; its death before the exit // frame is a failed run, not a cancellation (#573). Injecting a raw 0 // pins the clamp: it must never reach the lifecycle success paths. - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeRemoteWorktree() let stream = manager.eventStream() @@ -2010,7 +2010,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptSignalBasedTerminationReportsImmediately() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() @@ -2041,7 +2041,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptRerunClosesOldTabWithoutFiringCompletion() async { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let stream = manager.eventStream() @@ -2086,7 +2086,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptTabClosedManuallyReportsCancellation() async { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let stream = manager.eventStream() @@ -2114,7 +2114,7 @@ struct WorktreeTerminalManagerTests { } @Test func closeAllSurfacesCancelsPendingBlockingScripts() async { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let stream = manager.eventStream() @@ -2139,7 +2139,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptSuccessKeepsTabOpen() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() @@ -2171,7 +2171,7 @@ struct WorktreeTerminalManagerTests { } @Test func runScriptBlockingScriptTracksRunningState() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, command: "echo hi") @@ -2183,7 +2183,7 @@ struct WorktreeTerminalManagerTests { } @Test func stopRunScriptClosesRunTab() { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, command: "sleep 10") @@ -2195,7 +2195,7 @@ struct WorktreeTerminalManagerTests { } @Test func runScriptTabTitleResetsAfterSignalInterruption() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() let definition = ScriptDefinition(kind: .run, command: "sleep 10") @@ -2242,7 +2242,7 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptTabTitleResetsAfterFailure() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "exit 1"))) @@ -2272,7 +2272,7 @@ struct WorktreeTerminalManagerTests { } @Test func runBlockingScriptClosesLingeringFrozenTabOfSameKind() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() // First archive run, complete it, and confirm the tab is frozen. manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo first"))) @@ -2299,7 +2299,7 @@ struct WorktreeTerminalManagerTests { } @Test func residualProgressReportDoesNotResurrectDirtyOnFrozenTab() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) guard let state = manager.stateIfExists(for: worktree.id), @@ -2323,7 +2323,7 @@ struct WorktreeTerminalManagerTests { } @Test func selectTabWithValidIdChangesSelection() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() // Create two blocking script tabs so we have two tabs to switch between. @@ -2353,7 +2353,7 @@ struct WorktreeTerminalManagerTests { } @Test func selectTabWithStaleIdIsNoOp() { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "echo ok"))) @@ -2378,7 +2378,7 @@ struct WorktreeTerminalManagerTests { // MARK: - CLI query methods. @Test func listTabsReturnsTabIDs() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -2405,12 +2405,12 @@ struct WorktreeTerminalManagerTests { } @Test func listTabsReturnsNilForUnknownWorktree() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) #expect(manager.listTabs(worktreeID: "/nonexistent") == nil) } @Test func listSurfacesReturnsSortedSurfaceIDs() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -2435,19 +2435,19 @@ struct WorktreeTerminalManagerTests { } @Test func listSurfacesReturnsNilForUnknownWorktree() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) #expect(manager.listSurfaces(worktreeID: "/nonexistent", tabID: UUID().uuidString) == nil) } @Test func listSurfacesReturnsNilForInvalidTabID() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() _ = manager.state(for: worktree) #expect(manager.listSurfaces(worktreeID: worktree.id.rawValue, tabID: "not-a-uuid") == nil) } @Test func latestUnreadNotificationPicksNewestAcrossWorktrees() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktreeA = makeWorktree(id: "/tmp/repo/wt-a") let worktreeB = makeWorktree(id: "/tmp/repo/wt-b") let stateA = manager.state(for: worktreeA) @@ -2474,7 +2474,7 @@ struct WorktreeTerminalManagerTests { } @Test func latestUnreadNotificationSkipsNotificationsWithClosedSurfaces() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard @@ -2502,7 +2502,7 @@ struct WorktreeTerminalManagerTests { // Worktree B: only has a focusable unread at t=2, which is newer than // A's focusable fallback but older than A's orphaned newest. // Expected winner: B. - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktreeA = makeWorktree(id: "/tmp/repo/wt-a") let worktreeB = makeWorktree(id: "/tmp/repo/wt-b") let stateA = manager.state(for: worktreeA) @@ -2533,7 +2533,7 @@ struct WorktreeTerminalManagerTests { } @Test func latestUnreadNotificationReturnsNilWhenAllUnreadTargetClosedSurfaces() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) state.setNotificationsForTesting([ @@ -2543,7 +2543,7 @@ struct WorktreeTerminalManagerTests { } @Test func hasUnseenNotificationForTabIDWalksSplitTree() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard @@ -2582,7 +2582,7 @@ struct WorktreeTerminalManagerTests { } @Test func markNotificationReadOnlyTouchesMatchingId() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let first = makeNotification(surfaceID: UUID(), isRead: false, createdAt: .distantPast) @@ -2616,7 +2616,7 @@ struct WorktreeTerminalManagerTests { } @Test func coalescesConsecutiveIdenticalTaskStatusEvents() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let stream = manager.eventStream() @@ -2640,7 +2640,7 @@ struct WorktreeTerminalManagerTests { } @Test func coalescesConsecutiveIdenticalFocusEvents() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let stream = manager.eventStream() @@ -2664,7 +2664,7 @@ struct WorktreeTerminalManagerTests { } @Test func capsTheLiveEventBufferUnderBackpressure() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let stream = manager.eventStream() @@ -2687,7 +2687,7 @@ struct WorktreeTerminalManagerTests { } @Test func purgesCoalesceKeyOnTabTeardownSoIdenticalEventRedelivers() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let stream = manager.eventStream() @@ -2718,7 +2718,7 @@ struct WorktreeTerminalManagerTests { } @Test func neverCoalescesConsecutiveLifecycleEvents() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) let stream = manager.eventStream() @@ -2737,7 +2737,7 @@ struct WorktreeTerminalManagerTests { } @Test func coalescesPendingEventsBeforeSubscription() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -2760,12 +2760,12 @@ struct WorktreeTerminalManagerTests { } @Test func capsPendingLifecycleEventsBeforeSubscription() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) // No subscriber attached: lifecycle events fill pendingEvents and must cap. - let overflow = WorktreeTerminalManager.pendingEventCap + 50 + let overflow = WorktreeSurfaceManager.pendingEventCap + 50 for _ in 0.. WorktreeTerminalManager { + private func makeZmxBackedManager(probe: ZmxTestProbe, worktree: Worktree? = nil) -> WorktreeSurfaceManager { let zmxURL = makeFakeZmxBinary() return withDependencies { @@ -2843,7 +2843,7 @@ struct WorktreeTerminalManagerTests { listSessionsWithClients: { await probe.listSessionsWithClients() }, ) } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) _ = manager.state(for: worktree ?? makeWorktree()) return manager } @@ -3044,9 +3044,9 @@ struct WorktreeTerminalManagerTests { } private func nextEvent( - _ stream: AsyncStream, - matching predicate: (TerminalClient.Event) -> Bool - ) async -> TerminalClient.Event? { + _ stream: AsyncStream, + matching predicate: (SurfaceClient.Event) -> Bool + ) async -> SurfaceClient.Event? { for await event in stream where predicate(event) { return event } @@ -3068,7 +3068,7 @@ struct WorktreeTerminalManagerTests { } @Test func beginTabRenameCommandUsesSelectedTabWhenNoExplicitID() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3083,7 +3083,7 @@ struct WorktreeTerminalManagerTests { } @Test func beginTabRenameCommandUsesExplicitTabID() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3101,7 +3101,7 @@ struct WorktreeTerminalManagerTests { } @Test func beginTabRenameCommandIgnoresUnknownExplicitTabID() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3113,7 +3113,7 @@ struct WorktreeTerminalManagerTests { } @Test func beginTabRenameCommandIsNoOpWhenWorktreeHasNoTabs() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3123,7 +3123,7 @@ struct WorktreeTerminalManagerTests { } @Test func captureLayoutSnapshotExcludesBlockingScriptTabs() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) _ = state.createTab() @@ -3142,7 +3142,7 @@ struct WorktreeTerminalManagerTests { } @Test func captureLayoutSnapshotExcludesCompletedBlockingScriptTabs() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let stream = manager.eventStream() @@ -3168,7 +3168,7 @@ struct WorktreeTerminalManagerTests { } @Test func captureLayoutSnapshotSelectsLeftNeighborWhenSelectedTabIsExcluded() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard let tabA = state.createTab() else { @@ -3201,7 +3201,7 @@ struct WorktreeTerminalManagerTests { } @Test func performSplitActionRefusesNewSplitOnBlockingScriptTab() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() manager.handleCommand(.terminal(worktree, .runBlockingScript(kind: .archive, script: "sleep 10"))) guard let state = manager.stateIfExists(for: worktree.id), @@ -3219,7 +3219,7 @@ struct WorktreeTerminalManagerTests { } @Test func performSplitOperationRefusesDropOntoBlockingScriptTab() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) guard let regularTab = state.createTab(), @@ -3247,7 +3247,7 @@ struct WorktreeTerminalManagerTests { } @Test func restoreFromSnapshotIgnoresWhitespaceOnlyCustomTitle() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3274,7 +3274,7 @@ struct WorktreeTerminalManagerTests { } @Test func restoreFromSnapshotPreservesCustomTitle() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3328,7 +3328,7 @@ struct WorktreeTerminalManagerTests { // invalidation, they assert the underlying algebra stays per-tab pure. @Test func notificationOnTabBLeavesTabAUnseenCountUnchanged() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3357,7 +3357,7 @@ struct WorktreeTerminalManagerTests { } @Test func agentPresenceOnTabBLeavesTabAAgentsUnchanged() { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let state = manager.state(for: worktree) @@ -3390,7 +3390,7 @@ struct WorktreeTerminalManagerTests { } @Test func osc11BackgroundColorResolvesBackgroundKindToSRGB() { - let color = WorktreeTerminalManager.osc11BackgroundColor( + let color = WorktreeSurfaceManager.osc11BackgroundColor( kind: GHOSTTY_ACTION_COLOR_KIND_BACKGROUND, red: 26, green: 42, @@ -3405,35 +3405,35 @@ struct WorktreeTerminalManagerTests { @Test func osc11BackgroundColorIgnoresNonBackgroundKinds() { #expect( - WorktreeTerminalManager.osc11BackgroundColor( + WorktreeSurfaceManager.osc11BackgroundColor( kind: GHOSTTY_ACTION_COLOR_KIND_FOREGROUND, red: 1, green: 2, blue: 3) == nil) #expect( - WorktreeTerminalManager.osc11BackgroundColor( + WorktreeSurfaceManager.osc11BackgroundColor( kind: GHOSTTY_ACTION_COLOR_KIND_CURSOR, red: 1, green: 2, blue: 3) == nil) #expect( - WorktreeTerminalManager.osc11BackgroundColor(kind: nil, red: 1, green: 2, blue: 3) == nil) + WorktreeSurfaceManager.osc11BackgroundColor(kind: nil, red: 1, green: 2, blue: 3) == nil) } @Test func osc11BackgroundColorRequiresAllComponents() { #expect( - WorktreeTerminalManager.osc11BackgroundColor( + WorktreeSurfaceManager.osc11BackgroundColor( kind: GHOSTTY_ACTION_COLOR_KIND_BACKGROUND, red: nil, green: 2, blue: 3) == nil) #expect( - WorktreeTerminalManager.osc11BackgroundColor( + WorktreeSurfaceManager.osc11BackgroundColor( kind: GHOSTTY_ACTION_COLOR_KIND_BACKGROUND, red: 1, green: nil, blue: 3) == nil) #expect( - WorktreeTerminalManager.osc11BackgroundColor( + WorktreeSurfaceManager.osc11BackgroundColor( kind: GHOSTTY_ACTION_COLOR_KIND_BACKGROUND, red: 1, green: 2, blue: nil) == nil) } @Test func focusedSurfaceBackgroundInitializesToThemeFallback() { let runtime = GhosttyRuntime() - let manager = WorktreeTerminalManager(runtime: runtime) + let manager = WorktreeSurfaceManager(runtime: runtime) #expect(manager.focusedSurfaceBackground.matchesTint(runtime.backgroundColor())) } @Test func refreshFocusedSurfaceBackgroundDedupesUnchangedColor() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let notificationCount = LockIsolated(0) let observer = NotificationCenter.default.addObserver( forName: .ghosttyFocusedSurfaceBackgroundDidChange, @@ -3454,7 +3454,7 @@ struct WorktreeTerminalManagerTests { } @Test func switchingBetweenSelectionsDoesNotSpuriouslyPost() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let notificationCount = LockIsolated(0) let observer = NotificationCenter.default.addObserver( forName: .ghosttyFocusedSurfaceBackgroundDidChange, diff --git a/supacodeTests/WorktreeTerminalStateDropTests.swift b/supacodeTests/WorktreeSurfaceStateDropTests.swift similarity index 95% rename from supacodeTests/WorktreeTerminalStateDropTests.swift rename to supacodeTests/WorktreeSurfaceStateDropTests.swift index 24e1711ba..04a2bfded 100644 --- a/supacodeTests/WorktreeTerminalStateDropTests.swift +++ b/supacodeTests/WorktreeSurfaceStateDropTests.swift @@ -5,9 +5,9 @@ import Testing @testable import supacode @MainActor -struct WorktreeTerminalStateDropTests { +struct WorktreeSurfaceStateDropTests { private struct SplitTab { - let state: WorktreeTerminalState + let state: WorktreeSurfaceState let tabId: TerminalTabID let paneA: GhosttySurfaceView let paneB: GhosttySurfaceView @@ -58,7 +58,7 @@ struct WorktreeTerminalStateDropTests { /// A worktree state with one tab split into two terminal panes (A left, B right). private func makeSplitTab() -> SplitTab { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let state = manager.state(for: makeWorktree()) state.ensureInitialTab(focusing: true) From a663a742fae8105a74d71e1846815b58893e4ba3 Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Sat, 11 Jul 2026 16:18:46 -0400 Subject: [PATCH 09/13] Resolve terminal state per command arm so stop commands stay mint-free handleTerminalSurfaceCommand hoisted state(for:) above the switch, so .stopRunScript / .stopScript minted a WorktreeSurfaceState for a worktree that had none before stopBlockingScripts could take its stateIfExists path. The lookup now happens inside each arm, only where creating state is legitimate. --- .../WorktreeSurfaceManager.swift | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift index 35de7059f..f11d5da2f 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift @@ -277,29 +277,32 @@ final class WorktreeSurfaceManager { /// Terminal-kind command handler. A future kind adds a sibling handler and /// one dispatch arm in `handleCommand`; nothing here is shared. + /// + /// Resolve `state(for:)` per arm, never up front: the stop arms promise not + /// to mint state for a worktree that has none (`stopBlockingScripts` goes + /// through `stateIfExists`), and a hoisted lookup would break that promise. private func handleTerminalSurfaceCommand(_ command: TerminalSurfaceCommand, in worktree: Worktree) { - let terminal = state(for: worktree) switch command { case .runBlockingScript(let kind, let script): - _ = terminal.runBlockingScript(kind: kind, script) + _ = state(for: worktree).runBlockingScript(kind: kind, script) case .stopRunScript: stopBlockingScripts(in: worktree) { $0.stopRunScripts() } case .stopScript(let definitionID): stopBlockingScripts(in: worktree) { $0.stopScript(definitionID: definitionID) } case .performBindingAction(let action): - terminal.performBindingActionOnFocusedSurface(action) + state(for: worktree).performBindingActionOnFocusedSurface(action) case .performBindingActionOnSurface(let surfaceID, let action): - terminal.performBindingAction(action, onSurfaceID: surfaceID) + state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) case .startSearch: - terminal.performBindingActionOnFocusedSurface("start_search") + state(for: worktree).performBindingActionOnFocusedSurface("start_search") case .searchSelection: - terminal.performBindingActionOnFocusedSurface("search_selection") + state(for: worktree).performBindingActionOnFocusedSurface("search_selection") case .navigateSearchNext: - terminal.navigateSearchOnFocusedSurface(.next) + state(for: worktree).navigateSearchOnFocusedSurface(.next) case .navigateSearchPrevious: - terminal.navigateSearchOnFocusedSurface(.previous) + state(for: worktree).navigateSearchOnFocusedSurface(.previous) case .endSearch: - terminal.performBindingActionOnFocusedSurface("end_search") + state(for: worktree).performBindingActionOnFocusedSurface("end_search") } } From 0e798aba940e498d8194a3614e41743a21ebd607 Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Sat, 11 Jul 2026 16:28:23 -0400 Subject: [PATCH 10/13] Unify surface creation into one verb carrying spec and placement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createTab and splitSurface were the same operation — materialize a new leaf from a SurfaceSpec — differing only in where the leaf lands. SurfaceClient.Command.create(worktree, spec:placement:id:) replaces both; SurfacePlacement says .tab or .adjacent(tabID:anchor:direction:), so a future kind composes with every placement without new verbs. The .adjacent direction is a neutral 4-way enum mapped onto Ghostty's split primitive at the manager boundary. The old splitSurface payload carried the 2-way persisted SplitDirection with the placement hardcoded (vertical → down, else right), which made left/up splits unexpressible through the neutral verb. The persisted SplitDirection and the CLI wire format are untouched: snapshots keep encoding axis + child order, and deeplink h/v still map to right/down at the handler. Per-arm behavior is preserved: tab creation stays async for the setup-script read, a failed adjacent placement with an explicit id still emits surfaceCreationFailed, and .adjacent still selects the target tab first. --- supacode/Clients/Terminal/SurfaceClient.swift | 6 +- .../Features/App/Reducer/AppFeature.swift | 21 ++-- .../WorktreeSurfaceManager.swift | 99 +++++++++++-------- .../Terminal/Models/SurfacePlacement.swift | 25 +++++ .../Terminal/Models/SurfaceSpec.swift | 4 +- supacodeTests/AppFeatureDeeplinkTests.swift | 14 +-- .../AppFeatureOpenWorktreeTests.swift | 4 +- .../AppFeatureTerminalSetupScriptTests.swift | 4 +- 8 files changed, 113 insertions(+), 64 deletions(-) create mode 100644 supacode/Features/Terminal/Models/SurfacePlacement.swift diff --git a/supacode/Clients/Terminal/SurfaceClient.swift b/supacode/Clients/Terminal/SurfaceClient.swift index d9bafb28e..7427596b2 100644 --- a/supacode/Clients/Terminal/SurfaceClient.swift +++ b/supacode/Clients/Terminal/SurfaceClient.swift @@ -33,7 +33,8 @@ struct SurfaceClient { ) -> Void enum Command: Equatable { - case createTab(Worktree, spec: SurfaceSpec, id: UUID? = nil) + /// Materialize a new leaf: `spec` says what kind, `placement` says where. + case create(Worktree, spec: SurfaceSpec, placement: SurfacePlacement, id: UUID? = nil) case ensureInitialTab(Worktree, runSetupScriptIfNew: Bool, focusing: Bool) case closeFocusedTab(Worktree) case closeFocusedSurface(Worktree) @@ -41,9 +42,6 @@ struct SurfaceClient { case selectTab(Worktree, tabID: TerminalTabID) case selectTabAtIndex(Worktree, index: Int) case focusSurface(Worktree, tabID: TerminalTabID, surfaceID: UUID, input: String? = nil) - case splitSurface( - Worktree, tabID: TerminalTabID, surfaceID: UUID, direction: SplitDirection, - spec: SurfaceSpec, id: UUID? = nil) case destroyTab(Worktree, tabID: TerminalTabID) case destroySurface(Worktree, tabID: TerminalTabID, surfaceID: UUID) case beginTabRename(Worktree, tabID: TerminalTabID? = nil) diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 5df39ad7d..db60d4069 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -695,7 +695,8 @@ struct AppFeature { let shouldRunSetupScript = state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in - await surfaceClient.send(.createTab(worktree, spec: .terminal(runSetupScriptIfNew: shouldRunSetupScript))) + await surfaceClient.send( + .create(worktree, spec: .terminal(runSetupScriptIfNew: shouldRunSetupScript), placement: .tab)) } case .selectTerminalTabAtIndex(let tabNumber): @@ -1641,9 +1642,10 @@ struct AppFeature { state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in await surfaceClient.send( - .createTab( + .create( worktree, - spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: shouldRunSetupScript) + spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: shouldRunSetupScript), + placement: .tab ) ) } @@ -2018,7 +2020,7 @@ struct AppFeature { } guard let input, !input.isEmpty else { let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in - .createTab(worktree, spec: .terminal(runSetupScriptIfNew: true), id: id) + .create(worktree, spec: .terminal(runSetupScriptIfNew: true), placement: .tab, id: id) } return awaitingCompletion( effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) }, @@ -2030,7 +2032,7 @@ struct AppFeature { message: .command(input), action: action, state: &state) } let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in - .createTab(worktree, spec: .terminal(input: input), id: id) + .create(worktree, spec: .terminal(input: input), placement: .tab, id: id) } return awaitingCompletion( effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) }, @@ -2095,9 +2097,12 @@ struct AppFeature { message: .command(input), action: action, state: &state) } let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in - .splitSurface( - worktree, tabID: TerminalTabID(rawValue: tabID), surfaceID: surfaceID, - direction: direction, spec: .terminal(input: input), id: id) + .create( + worktree, spec: .terminal(input: input), + placement: .adjacent( + tabID: TerminalTabID(rawValue: tabID), anchor: surfaceID, + direction: direction == .vertical ? .down : .right), + id: id) } return awaitingCompletion( effect, match: id.map { .surfaceSplit(worktreeID: worktreeID, surfaceID: $0) }, diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift index f11d5da2f..2d7b5fcac 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift @@ -309,18 +309,8 @@ final class WorktreeSurfaceManager { // swiftlint:disable:next cyclomatic_complexity private func handleTabCommand(_ command: SurfaceClient.Command) -> Bool { switch command { - case .createTab(let worktree, let spec, let id): - switch spec { - case .terminal(let terminalSpec): - Task { - createTabAsync( - in: worktree, - runSetupScriptIfNew: terminalSpec.runSetupScriptIfNew, - initialInput: terminalSpec.input, - tabID: id - ) - } - } + case .create(let worktree, let spec, let placement, let id): + handleCreate(in: worktree, spec: spec, placement: placement, id: id) case .ensureInitialTab(let worktree, let runSetupScriptIfNew, let focusing): let state = state(for: worktree) { runSetupScriptIfNew } state.ensureInitialTab(focusing: focusing) @@ -346,31 +336,6 @@ final class WorktreeSurfaceManager { if let input, !input.isEmpty { terminal.focusAndInsertText(input + "\r") } - case .splitSurface(let worktree, let tabID, let surfaceID, let direction, let spec, let id): - let terminal = state(for: worktree) - terminal.selectTab(tabID) - let ghosttyDirection: GhosttySplitAction.NewDirection = direction == .vertical ? .down : .right - let initialInput: String? - switch spec { - case .terminal(let terminalSpec): - initialInput = BlockingScriptRunner.makeCommandInput(script: terminalSpec.input ?? "") - } - let splitSucceeded = terminal.performSplitAction( - .newSplit(direction: ghosttyDirection), - for: surfaceID, - newSurfaceID: id, - initialInput: initialInput - ) - guard splitSucceeded else { - terminalLogger.warning("splitSurface: failed for surface \(surfaceID) in worktree \(worktree.id).") - if let id { - emit( - .surfaceCreationFailed( - worktreeID: worktree.id, attemptedID: id, - message: "Could not create the split surface.")) - } - break - } case .destroyTab(let worktree, let tabID): let terminal = state(for: worktree) guard terminal.tabManager.tabs.contains(where: { $0.id == tabID }) else { @@ -396,6 +361,62 @@ final class WorktreeSurfaceManager { return true } + /// One creation path for every spec × placement combination. A future + /// surface kind adds an arm to the inner spec switches; the placement + /// handling is kind-agnostic and stays untouched. + private func handleCreate(in worktree: Worktree, spec: SurfaceSpec, placement: SurfacePlacement, id: UUID?) { + switch placement { + case .tab: + switch spec { + case .terminal(let terminalSpec): + // Async: tab creation reads the repository setup script off-main first. + Task { + createTabAsync( + in: worktree, + runSetupScriptIfNew: terminalSpec.runSetupScriptIfNew, + initialInput: terminalSpec.input, + tabID: id + ) + } + } + case .adjacent(let tabID, let surfaceID, let direction): + let terminal = state(for: worktree) + terminal.selectTab(tabID) + let initialInput: String? + switch spec { + case .terminal(let terminalSpec): + initialInput = BlockingScriptRunner.makeCommandInput(script: terminalSpec.input ?? "") + } + let splitSucceeded = terminal.performSplitAction( + .newSplit(direction: Self.ghosttyDirection(for: direction)), + for: surfaceID, + newSurfaceID: id, + initialInput: initialInput + ) + guard splitSucceeded else { + terminalLogger.warning("create: split failed for surface \(surfaceID) in worktree \(worktree.id).") + if let id { + emit( + .surfaceCreationFailed( + worktreeID: worktree.id, attemptedID: id, + message: "Could not create the split surface.")) + } + return + } + } + } + + /// The neutral placement direction stays Ghostty-free; the mapping onto the + /// terminal's split primitive lives here at the dispatch boundary. + private static func ghosttyDirection(for direction: SurfacePlacement.Direction) -> GhosttySplitAction.NewDirection { + switch direction { + case .right: .right + case .left: .left + case .down: .down + case .up: .top + } + } + private func setImagePasteAgents(_ agents: Set, onSurfaceID surfaceID: UUID) { for state in states.values where state.setImagePasteAgents(agents, onSurfaceID: surfaceID) { return @@ -426,8 +447,8 @@ final class WorktreeSurfaceManager { terminalLogger.info("Selected worktree \(id?.rawValue ?? "nil")") case .setImagePasteAgents(let surfaceID, let agents): setImagePasteAgents(agents, onSurfaceID: surfaceID) - case .createTab, .ensureInitialTab, .closeFocusedTab, .closeFocusedSurface, .selectTab, - .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, + case .create, .ensureInitialTab, .closeFocusedTab, .closeFocusedSurface, .selectTab, + .selectTabAtIndex, .focusSurface, .destroyTab, .destroySurface, .beginTabRename, .terminal: assertionFailure("Unhandled terminal command reached management handler: \(command)") } diff --git a/supacode/Features/Terminal/Models/SurfacePlacement.swift b/supacode/Features/Terminal/Models/SurfacePlacement.swift new file mode 100644 index 000000000..aa294271e --- /dev/null +++ b/supacode/Features/Terminal/Models/SurfacePlacement.swift @@ -0,0 +1,25 @@ +import Foundation + +/// Where a newly created surface lands, independent of what kind of surface it +/// is (`SurfaceSpec`). The creation verb carries spec × placement so "make a +/// new tab" and "split an existing pane" are one operation differing only in +/// destination, and a future surface kind composes with every placement for +/// free. +enum SurfacePlacement: Equatable, Sendable { + /// A new tab whose single leaf is the new surface. + case tab + /// A new sibling pane split off `anchor` in `tabID`. The anchor is an explicit + /// surface id (not "the focused pane") so CLI/deeplink targeting stays + /// deterministic; UI call sites resolve focus at dispatch. + case adjacent(tabID: TerminalTabID, anchor: UUID, direction: Direction) + + /// Full 4-way placement for `.adjacent`. Distinct from the persisted 2-way + /// `SplitDirection` (axis + child order in snapshots), which stays untouched: + /// only the command payload needs to say "left" or "up". + enum Direction: Equatable, Sendable, CaseIterable { + case right + case left + case down + case up + } +} diff --git a/supacode/Features/Terminal/Models/SurfaceSpec.swift b/supacode/Features/Terminal/Models/SurfaceSpec.swift index 388dcdf17..09a0e5458 100644 --- a/supacode/Features/Terminal/Models/SurfaceSpec.swift +++ b/supacode/Features/Terminal/Models/SurfaceSpec.swift @@ -1,8 +1,8 @@ import Foundation /// Value-level "what to create" descriptor for a new surface, distinct from the -/// view-level `SurfaceContent`. Creation verbs (`createTab`, `splitSurface`) -/// carry one of these so "which kind" is data instead of verb spelling; adding +/// view-level `SurfaceContent`. The creation verb carries one of these (plus a +/// `SurfacePlacement`) so "which kind" is data instead of verb spelling; adding /// a surface kind adds a case here and the compiler forces every dispatch /// switch to handle it. enum SurfaceSpec: Equatable, Sendable { diff --git a/supacodeTests/AppFeatureDeeplinkTests.swift b/supacodeTests/AppFeatureDeeplinkTests.swift index 18bc4fdbb..2997803ca 100644 --- a/supacodeTests/AppFeatureDeeplinkTests.swift +++ b/supacodeTests/AppFeatureDeeplinkTests.swift @@ -1005,7 +1005,7 @@ struct AppFeatureDeeplinkTests { tabID: tabUUID, surfaceID: surfaceUUID, direction: .vertical, input: nil, id: nil)))) #expect(store.state.deeplinkInputConfirmation == nil) let hasSplit = sent.value.contains(where: { - if case .splitSurface = $0 { return true } + if case .create(_, _, .adjacent, _) = $0 { return true } return false }) #expect(hasSplit) @@ -1056,7 +1056,7 @@ struct AppFeatureDeeplinkTests { } } let hasSplit = sent.value.contains(where: { - if case .splitSurface = $0 { return true } + if case .create(_, _, .adjacent, _) = $0 { return true } return false }) #expect(hasSplit) @@ -1267,7 +1267,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTab(worktree, spec: .terminal(input: "echo hello"), id: nil) + .create(worktree, spec: .terminal(input: "echo hello"), placement: .tab, id: nil) ) ) } @@ -1302,7 +1302,7 @@ struct AppFeatureDeeplinkTests { } #expect( sent.value.contains( - .createTab(worktree, spec: .terminal(input: "echo hello"), id: nil) + .create(worktree, spec: .terminal(input: "echo hello"), placement: .tab, id: nil) ) ) await store.finish() @@ -1420,7 +1420,7 @@ struct AppFeatureDeeplinkTests { await store.send(.deeplink(.worktree(id: worktree.id, action: .tabNew(input: nil, id: nil)))) let hasCreateTab = sent.value.contains(where: { - if case .createTab(let target, _, _) = $0 { return target.id == worktree.id } + if case .create(let target, _, _, _) = $0 { return target.id == worktree.id } return false }) #expect(hasCreateTab) @@ -2081,7 +2081,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTab(worktree, spec: .terminal(input: "echo test"), id: nil) + .create(worktree, spec: .terminal(input: "echo test"), placement: .tab, id: nil) ) ) } @@ -2506,7 +2506,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTab(worktree, spec: .terminal(input: "echo test"), id: nil) + .create(worktree, spec: .terminal(input: "echo test"), placement: .tab, id: nil) ) ) } diff --git a/supacodeTests/AppFeatureOpenWorktreeTests.swift b/supacodeTests/AppFeatureOpenWorktreeTests.swift index 7698436f4..15be69641 100644 --- a/supacodeTests/AppFeatureOpenWorktreeTests.swift +++ b/supacodeTests/AppFeatureOpenWorktreeTests.swift @@ -36,7 +36,7 @@ struct AppFeatureOpenWorktreeTests { #expect(context.openedActions.value.isEmpty) #expect( context.terminalCommands.value == [ - .createTab(context.worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: false)) + .create(context.worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: false), placement: .tab) ] ) await store.finish() @@ -49,7 +49,7 @@ struct AppFeatureOpenWorktreeTests { await store.receive(\.repositories.delegate.openWorktreeInApp) #expect( context.terminalCommands.value == [ - .createTab(context.worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: true)) + .create(context.worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: true), placement: .tab) ] ) await store.finish() diff --git a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift index 207ed936b..c60c99a24 100644 --- a/supacodeTests/AppFeatureTerminalSetupScriptTests.swift +++ b/supacodeTests/AppFeatureTerminalSetupScriptTests.swift @@ -38,7 +38,7 @@ struct AppFeatureTerminalSetupScriptTests { $0.repositories.applyPostReduceCacheRecomputes([.sidebarStructure, .selectedWorktreeSlice]) } await store.finish() - #expect(sent.value == [.createTab(worktree, spec: .terminal(runSetupScriptIfNew: true), id: nil)]) + #expect(sent.value == [.create(worktree, spec: .terminal(runSetupScriptIfNew: true), placement: .tab, id: nil)]) } @Test(.dependencies) func newTerminalWithoutSetupScriptDoesNotConsume() async { @@ -64,7 +64,7 @@ struct AppFeatureTerminalSetupScriptTests { await store.send(.newTerminal) await store.finish() - #expect(sent.value == [.createTab(worktree, spec: .terminal(runSetupScriptIfNew: false), id: nil)]) + #expect(sent.value == [.create(worktree, spec: .terminal(runSetupScriptIfNew: false), placement: .tab, id: nil)]) } @Test(.dependencies) func tabCreatedDoesNotConsumeSetupScript() async { From 92190c9810c87c2ac739e707c150cd8aaae1eab7 Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Sat, 11 Jul 2026 16:33:28 -0400 Subject: [PATCH 11/13] Dispatch app-initiated splits through the surface-creation verb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The File menu's split arm and the tab-bar split buttons went through the terminal-kind binding channel (performBindingAction with a new_split string), whose Ghostty round-trip contributes nothing for splits — Ghostty parses the string and bounces it straight back via bridge.onSplitAction. Both now dispatch .create with an .adjacent placement, resolving the anchor from selectedTabID / selectedSurfaceID at dispatch, which also gives menu splits the full 4-way range through the neutral verb. The input path is untouched: Ghostty-originated keystrokes on a focused terminal still arrive via bridge.onSplitAction so custom Ghostty keybindings keep working, ghosttyBinding stays for mirroring those bindings as menu shortcut displays, and performBindingAction remains for genuinely Ghostty-owned verbs. Bindings are the input path; verbs are the dispatch path for everything the app initiates. --- supacode/Domain/SplitDirection.swift | 12 +++++++ .../Features/App/Reducer/AppFeature.swift | 12 ++++++- .../Views/WorktreeTerminalTabsView.swift | 8 ++++- .../AppFeatureSplitTerminalTests.swift | 34 +++++++++++++++++-- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/supacode/Domain/SplitDirection.swift b/supacode/Domain/SplitDirection.swift index d71488b58..cf725e960 100644 --- a/supacode/Domain/SplitDirection.swift +++ b/supacode/Domain/SplitDirection.swift @@ -37,6 +37,18 @@ enum TerminalSplitMenuDirection: Equatable, Sendable, CaseIterable { } } + /// The neutral placement this menu direction dispatches through the + /// surface-creation verb. `ghosttyBinding` above stays purely for shortcut + /// display, mirroring the user's Ghostty keybinding on the menu item. + var placementDirection: SurfacePlacement.Direction { + switch self { + case .right: .right + case .left: .left + case .down: .down + case .up: .up + } + } + var systemImage: String { switch self { case .right: "rectangle.righthalf.inset.filled" diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index db60d4069..c46f0061d 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -718,8 +718,18 @@ struct AppFeature { else { return .none } + // Capture the anchor synchronously: an async dispatch would race + // AppKit focus reshuffle (e.g. a dismissing palette) for the target. + guard let tabID = surfaceClient.selectedTabID(worktree.id), + let surfaceID = surfaceClient.selectedSurfaceID(worktree.id) + else { + return .none + } return .run { _ in - await surfaceClient.send(.terminal(worktree, .performBindingAction(direction.ghosttyBinding))) + await surfaceClient.send( + .create( + worktree, spec: .terminal(), + placement: .adjacent(tabID: tabID, anchor: surfaceID, direction: direction.placementDirection))) } case .jumpToLatestUnread: diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index c7ad81678..889f900df 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -32,7 +32,13 @@ struct WorktreeTerminalTabsView: View { terminalsStore: terminalsStore, createTab: createTab, split: { direction in - _ = state.performBindingActionOnFocusedSurface(direction.ghosttyBinding) + guard let tabID = state.tabManager.selectedTabId, + let surfaceID = state.activeSurfaceID(for: tabID) + else { return } + manager.handleCommand( + .create( + worktree, spec: .terminal(), + placement: .adjacent(tabID: tabID, anchor: surfaceID, direction: direction.placementDirection))) }, canSplit: state.tabManager.selectedTabId.flatMap { state.activeSurfaceID(for: $0) } != nil, closeTab: { tabId in diff --git a/supacodeTests/AppFeatureSplitTerminalTests.swift b/supacodeTests/AppFeatureSplitTerminalTests.swift index 7275ef5aa..dffbbad19 100644 --- a/supacodeTests/AppFeatureSplitTerminalTests.swift +++ b/supacodeTests/AppFeatureSplitTerminalTests.swift @@ -21,8 +21,10 @@ struct AppFeatureSplitTerminalTests { } @Test(.dependencies, arguments: TerminalSplitMenuDirection.allCases) - func splitTerminalForwardsGhosttyBinding(direction: TerminalSplitMenuDirection) async { + func splitTerminalDispatchesAdjacentCreateAtFocusedSurface(direction: TerminalSplitMenuDirection) async { let worktree = makeWorktree() + let tabID = TerminalTabID() + let surfaceID = UUID() let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( @@ -32,6 +34,8 @@ struct AppFeatureSplitTerminalTests { ) { AppFeature() } withDependencies: { + $0.surfaceClient.selectedTabID = { _ in tabID } + $0.surfaceClient.selectedSurfaceID = { _ in surfaceID } $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } @@ -39,7 +43,33 @@ struct AppFeatureSplitTerminalTests { await store.send(.splitTerminal(direction)) await store.finish() - #expect(sent.value == [.terminal(worktree, .performBindingAction(direction.ghosttyBinding))]) + #expect( + sent.value == [ + .create( + worktree, spec: .terminal(), + placement: .adjacent(tabID: tabID, anchor: surfaceID, direction: direction.placementDirection)) + ]) + } + + @Test(.dependencies) func splitTerminalWithoutFocusedSurfaceIsNoop() async { + let worktree = makeWorktree() + let store = TestStore( + initialState: AppFeature.State( + repositories: makeRepositoriesState(worktree: worktree), + settings: SettingsFeature.State() + ) + ) { + AppFeature() + } withDependencies: { + $0.surfaceClient.selectedTabID = { _ in nil } + $0.surfaceClient.selectedSurfaceID = { _ in nil } + $0.surfaceClient.send = { _ in + Issue.record("surfaceClient.send should not be called without a focused surface") + } + } + + await store.send(.splitTerminal(.right)) + await store.finish() } @Test(.dependencies) func splitTerminalWithoutSelectionIsNoop() async { From 7d5cd4e16399bf024882cb109e6d9e71488aebde Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Sat, 11 Jul 2026 16:37:11 -0400 Subject: [PATCH 12/13] Restore snapshot leaves through one kind-dispatched factory restoreFromSnapshot and createRestorationSplit each unwrapped the persisted SurfaceSnapshot to its terminal payload and called createSurface with the same argument shape. A private makeSurface(from:) is now the single kind dispatch for snapshot restore, and the restore walk (restoreLayoutNode / createRestorationSplit anchors) is typed against SurfaceView, so a future snapshot kind registers in exactly one place. No behavior change. --- .../Models/WorktreeSurfaceState.swift | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift b/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift index 3e63091dd..49afbf6d7 100644 --- a/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift +++ b/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift @@ -1286,12 +1286,6 @@ final class WorktreeSurfaceState { pendingSetupScript = false for (index, tabSnapshot) in snapshot.tabs.enumerated() { - // Kind dispatch on the persisted leaf; a new snapshot kind adds a case. - let firstLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot - switch tabSnapshot.layout.firstLeaf { - case .terminal(let leaf): firstLeaf = leaf - } - let workingDir = firstLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } let context: ghostty_surface_context_e = index == 0 ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB let tabId = tabManager.createTab( @@ -1304,14 +1298,7 @@ final class WorktreeSurfaceState { if let customTitle = tabSnapshot.customTitle { tabManager.setCustomTitle(tabId, title: customTitle) } - let surface = createSurface( - tabId: tabId, - initialInput: nil, - workingDirectoryOverride: workingDir, - inheritingFromSurfaceId: nil, - context: context, - surfaceID: firstLeaf.id, - ) + let surface = makeSurface(from: tabSnapshot.layout.firstLeaf, tabId: tabId, context: context) let tree = SplitTree(view: surface) setTree(tree, for: tabId) setFocusedSurface(surface.id, for: tabId) @@ -1368,18 +1355,11 @@ final class WorktreeSurfaceState { private func restoreLayoutNode( _ node: TerminalLayoutSnapshot.LayoutNode, - anchor: GhosttySurfaceView, + anchor: SurfaceView, tabId: TerminalTabID ) { guard case .split(let split) = node else { return } - // Create the right child by splitting the anchor. Kind dispatch on the - // persisted leaf; a new snapshot kind adds a case. - let rightLeaf: TerminalLayoutSnapshot.TerminalSurfaceSnapshot - switch split.right.firstLeaf { - case .terminal(let leaf): rightLeaf = leaf - } - let rightWorkingDir = rightLeaf.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } let direction: SplitTree.NewDirection = split.direction == .horizontal ? .right : .down @@ -1388,9 +1368,8 @@ final class WorktreeSurfaceState { at: anchor, direction: direction, ratio: split.ratio, - workingDirectory: rightWorkingDir, + leaf: split.right.firstLeaf, tabId: tabId, - surfaceID: rightLeaf.id, ) else { layoutLogger.warning("Skipping subtree restoration for tab \(tabId.rawValue)") @@ -1403,34 +1382,52 @@ final class WorktreeSurfaceState { } private func createRestorationSplit( - at anchor: GhosttySurfaceView, + at anchor: SurfaceView, direction: SplitTree.NewDirection, ratio: Double, - workingDirectory: URL?, - tabId: TerminalTabID, - surfaceID: UUID? = nil - ) -> GhosttySurfaceView? { + leaf: TerminalLayoutSnapshot.SurfaceSnapshot, + tabId: TerminalTabID + ) -> SurfaceView? { guard var tree = trees[tabId] else { return nil } - let newSurface = createSurface( - tabId: tabId, - initialInput: nil, - workingDirectoryOverride: workingDirectory, - inheritingFromSurfaceId: anchor.id, - context: GHOSTTY_SURFACE_CONTEXT_SPLIT, - surfaceID: surfaceID, - ) + let newSurface = makeSurface( + from: leaf, tabId: tabId, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, inheritingFrom: anchor.id) do { tree = try tree.inserting(view: newSurface, at: anchor, direction: direction, ratio: ratio) setTree(tree, for: tabId) return newSurface } catch { layoutLogger.warning("Failed to restore split for tab \(tabId.rawValue): \(error)") - newSurface.closeSurface() + switch newSurface.content { + case .terminal(let surface): surface.closeSurface() + } discardSurfaceBookkeeping(for: newSurface.id) return nil } } + /// Materializes the restored view for one persisted leaf. The single kind + /// dispatch for snapshot restore: a future snapshot kind adds a case here + /// and both the first-leaf and split-restore paths pick it up. + private func makeSurface( + from leaf: TerminalLayoutSnapshot.SurfaceSnapshot, + tabId: TerminalTabID, + context: ghostty_surface_context_e, + inheritingFrom anchorID: UUID? = nil + ) -> SurfaceView { + switch leaf { + case .terminal(let terminal): + let workingDir = terminal.workingDirectory.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } + return createSurface( + tabId: tabId, + initialInput: nil, + workingDirectoryOverride: workingDir, + inheritingFromSurfaceId: anchorID, + context: context, + surfaceID: terminal.id, + ) + } + } + func needsSetupScript() -> Bool { pendingSetupScript } From 89c90f424ecba20d682529f632d2fa9fcb3192c5 Mon Sep 17 00:00:00 2001 From: Jeremy Bower Date: Sat, 11 Jul 2026 18:51:05 -0400 Subject: [PATCH 13/13] Move terminal-kind command handling onto TerminalSurfaceManager The terminal-kind command handler and its stop helper lived as private funcs on the neutral WorktreeSurfaceManager, so each new kind would have grown the shared file by a handler's worth of kind code. They now live on TerminalSurfaceManager, a kind-level coordinator the core routes to without interpreting the payload; a future kind adds a sibling manager and one dispatch arm. One coordinator serves every worktree: commands arrive addressed by Worktree, and per-worktree resolution stays behind the core's state(for:) / stateIfExists(for:) split so the stop arms keep acting on existing state without minting any. forceEmitProjection widens to internal for the stale-mirror reconcile path. --- .../TerminalSurfaceManager.swift | 63 +++++++++++++++++++ .../WorktreeSurfaceManager.swift | 54 +++------------- 2 files changed, 70 insertions(+), 47 deletions(-) create mode 100644 supacode/Features/Terminal/BusinessLogic/TerminalSurfaceManager.swift diff --git a/supacode/Features/Terminal/BusinessLogic/TerminalSurfaceManager.swift b/supacode/Features/Terminal/BusinessLogic/TerminalSurfaceManager.swift new file mode 100644 index 000000000..9b634a43a --- /dev/null +++ b/supacode/Features/Terminal/BusinessLogic/TerminalSurfaceManager.swift @@ -0,0 +1,63 @@ +import Foundation +import SupacodeSettingsShared + +private let terminalLogger = SupaLogger("Terminal") + +/// Terminal-kind command handling, owned by the terminal kind rather than the +/// neutral core: `WorktreeSurfaceManager`'s `.terminal` dispatch arm forwards +/// here and never interprets the payload. A future surface kind adds a sibling +/// manager and one dispatch arm; nothing in this file is shared. +/// +/// One instance serves every worktree — commands arrive addressed by +/// `Worktree`, and per-worktree resolution must stay behind the core's +/// `state(for:)` / `stateIfExists(for:)` split so the stop arms can act on +/// existing state without minting any. +@MainActor +final class TerminalSurfaceManager { + private unowned let core: WorktreeSurfaceManager + + init(core: WorktreeSurfaceManager) { + self.core = core + } + + /// Resolve state per arm, never up front: the stop arms promise not to mint + /// state for a worktree that has none (`stopBlockingScripts` goes through + /// `stateIfExists`), and a hoisted lookup would break that promise. + func handle(_ command: TerminalSurfaceCommand, in worktree: Worktree) { + switch command { + case .runBlockingScript(let kind, let script): + _ = core.state(for: worktree).runBlockingScript(kind: kind, script) + case .stopRunScript: + stopBlockingScripts(in: worktree) { $0.stopRunScripts() } + case .stopScript(let definitionID): + stopBlockingScripts(in: worktree) { $0.stopScript(definitionID: definitionID) } + case .performBindingAction(let action): + core.state(for: worktree).performBindingActionOnFocusedSurface(action) + case .performBindingActionOnSurface(let surfaceID, let action): + core.state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) + case .startSearch: + core.state(for: worktree).performBindingActionOnFocusedSurface("start_search") + case .searchSelection: + core.state(for: worktree).performBindingActionOnFocusedSurface("search_selection") + case .navigateSearchNext: + core.state(for: worktree).navigateSearchOnFocusedSurface(.next) + case .navigateSearchPrevious: + core.state(for: worktree).navigateSearchOnFocusedSurface(.previous) + case .endSearch: + core.state(for: worktree).performBindingActionOnFocusedSurface("end_search") + } + } + + /// Runs `stop` on the worktree's existing terminal state, never minting one. + /// A miss with a live state means the caller acted on a stale mirror, so force + /// a fresh projection emit past the dedupe cache to reconcile it (#573). + private func stopBlockingScripts(in worktree: Worktree, using stop: (WorktreeSurfaceState) -> Bool) { + guard let state = core.stateIfExists(for: worktree.id) else { + terminalLogger.warning("Stop requested for \(worktree.id) with no terminal state") + return + } + guard !stop(state) else { return } + terminalLogger.warning("Stop requested for \(worktree.id) with no matching script; re-emitting projection") + core.forceEmitProjection(for: worktree.id) + } +} diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift index 2d7b5fcac..cba92436e 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift @@ -137,6 +137,10 @@ final class WorktreeSurfaceManager { var onDeeplinkCommand: ((URL, Int32) -> Void)? /// Query received from the CLI via socket. Parameters: resource name, params, client FD. var onQuery: ((String, [String: String], Int32) -> Void)? + /// Terminal-kind command handling. The neutral core only routes to it and + /// never interprets the kind-scoped payload; a future kind adds a sibling. + /// Lazy solely for the `self` back-reference; there is no deferred-work intent. + @ObservationIgnored private lazy var terminalCommands = TerminalSurfaceManager(core: self) init>( runtime: GhosttyRuntime, @@ -263,10 +267,10 @@ final class WorktreeSurfaceManager { } func handleCommand(_ command: SurfaceClient.Command) { - // Kind-scoped commands dispatch straight to their kind's handler; the + // Kind-scoped commands dispatch straight to their kind's manager; the // neutral handlers below never see them. if case .terminal(let worktree, let surfaceCommand) = command { - handleTerminalSurfaceCommand(surfaceCommand, in: worktree) + terminalCommands.handle(surfaceCommand, in: worktree) return } if handleTabCommand(command) { @@ -275,37 +279,6 @@ final class WorktreeSurfaceManager { handleManagementCommand(command) } - /// Terminal-kind command handler. A future kind adds a sibling handler and - /// one dispatch arm in `handleCommand`; nothing here is shared. - /// - /// Resolve `state(for:)` per arm, never up front: the stop arms promise not - /// to mint state for a worktree that has none (`stopBlockingScripts` goes - /// through `stateIfExists`), and a hoisted lookup would break that promise. - private func handleTerminalSurfaceCommand(_ command: TerminalSurfaceCommand, in worktree: Worktree) { - switch command { - case .runBlockingScript(let kind, let script): - _ = state(for: worktree).runBlockingScript(kind: kind, script) - case .stopRunScript: - stopBlockingScripts(in: worktree) { $0.stopRunScripts() } - case .stopScript(let definitionID): - stopBlockingScripts(in: worktree) { $0.stopScript(definitionID: definitionID) } - case .performBindingAction(let action): - state(for: worktree).performBindingActionOnFocusedSurface(action) - case .performBindingActionOnSurface(let surfaceID, let action): - state(for: worktree).performBindingAction(action, onSurfaceID: surfaceID) - case .startSearch: - state(for: worktree).performBindingActionOnFocusedSurface("start_search") - case .searchSelection: - state(for: worktree).performBindingActionOnFocusedSurface("search_selection") - case .navigateSearchNext: - state(for: worktree).navigateSearchOnFocusedSurface(.next) - case .navigateSearchPrevious: - state(for: worktree).navigateSearchOnFocusedSurface(.previous) - case .endSearch: - state(for: worktree).performBindingActionOnFocusedSurface("end_search") - } - } - // swiftlint:disable:next cyclomatic_complexity private func handleTabCommand(_ command: SurfaceClient.Command) -> Bool { switch command { @@ -1298,23 +1271,10 @@ final class WorktreeSurfaceManager { emit(.terminal(.hasAnySurfaceChanged(hasAny: hasAny))) } - /// Runs `stop` on the worktree's existing terminal state, never minting one. - /// A miss with a live state means the caller acted on a stale mirror, so force - /// a fresh projection emit past the dedupe cache to reconcile it (#573). - private func stopBlockingScripts(in worktree: Worktree, using stop: (WorktreeSurfaceState) -> Bool) { - guard let state = stateIfExists(for: worktree.id) else { - terminalLogger.warning("Stop requested for \(worktree.id) with no terminal state") - return - } - guard !stop(state) else { return } - terminalLogger.warning("Stop requested for \(worktree.id) with no matching script; re-emitting projection") - forceEmitProjection(for: worktree.id) - } - /// Re-delivers a worktree's projection past both dedupe layers, so a row that /// diverged from the cache (a reducer-side archived-strip) is reconciled even /// when the projection value is unchanged (#573). - private func forceEmitProjection(for id: Worktree.ID) { + func forceEmitProjection(for id: Worktree.ID) { lastEmittedProjections.removeValue(forKey: id) lastEmittedCoalescable.removeValue(forKey: .worktreeProjection(id)) emitProjection(for: id)