diff --git a/Project.swift b/Project.swift index ff5080d5d..e788751df 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", ] @@ -107,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/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/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 55% rename from supacode/Clients/Terminal/TerminalClient.swift rename to supacode/Clients/Terminal/SurfaceClient.swift index f6b379e03..7427596b2 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 @@ -33,28 +33,15 @@ struct TerminalClient { ) -> Void enum Command: Equatable { - case createTab(Worktree, runSetupScriptIfNew: Bool, id: UUID? = nil) - case createTabWithInput(Worktree, input: String, runSetupScriptIfNew: Bool, 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 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) - case splitSurface( - Worktree, tabID: TerminalTabID, surfaceID: UUID, direction: SplitDirection, - input: String?, id: UUID? = nil) case destroyTab(Worktree, tabID: TerminalTabID) case destroySurface(Worktree, tabID: TerminalTabID, surfaceID: UUID) case beginTabRename(Worktree, tabID: TerminalTabID? = nil) @@ -62,6 +49,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 { @@ -71,11 +61,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) @@ -85,7 +71,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) @@ -97,59 +83,55 @@ 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) } } -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/Domain/SplitDirection.swift b/supacode/Domain/SplitDirection.swift index eed1d971e..cf725e960 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 @@ -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" @@ -69,7 +81,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/App/Models/WorktreeMenuSnapshot.swift b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift index 415de78c8..1c2b50a50 100644 --- a/supacode/Features/App/Models/WorktreeMenuSnapshot.swift +++ b/supacode/Features/App/Models/WorktreeMenuSnapshot.swift @@ -97,22 +97,20 @@ 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 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. // `.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 019bc7e8e..c46f0061d 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(.runBlockingScript(worktree, 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,8 @@ struct AppFeature { let shouldRunSetupScript = state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in - await terminalClient.send(.createTab(worktree, runSetupScriptIfNew: shouldRunSetupScript)) + await surfaceClient.send( + .create(worktree, spec: .terminal(runSetupScriptIfNew: shouldRunSetupScript), placement: .tab)) } case .selectTerminalTabAtIndex(let tabNumber): @@ -708,7 +709,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): @@ -717,12 +718,22 @@ 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 terminalClient.send(.performBindingAction(worktree, action: direction.ghosttyBinding)) + await surfaceClient.send( + .create( + worktree, spec: .terminal(), + placement: .adjacent(tabID: tabID, anchor: surfaceID, direction: direction.placementDirection))) } 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 +750,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,8 +803,8 @@ 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( - .runBlockingScript(worktree, kind: .script(definition), script: definition.command) + await surfaceClient.send( + .terminal(worktree, .runBlockingScript(kind: .script(definition), script: definition.command)) ) } @@ -802,7 +813,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.stopScript(worktree, definitionID: definition.id)) + await surfaceClient.send(.terminal(worktree, .stopScript(definitionID: definition.id))) } case .stopRunScripts: @@ -810,7 +821,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.stopRunScript(worktree)) + await surfaceClient.send(.terminal(worktree, .stopRunScript)) } case .closeTab: @@ -819,7 +830,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 +838,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.closeFocusedSurface(worktree)) + await surfaceClient.send(.closeFocusedSurface(worktree)) } case .startSearch: @@ -835,7 +846,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.startSearch(worktree)) + await surfaceClient.send(.terminal(worktree, .startSearch)) } case .searchSelection: @@ -843,7 +854,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.searchSelection(worktree)) + await surfaceClient.send(.terminal(worktree, .searchSelection)) } case .navigateSearchNext: @@ -851,7 +862,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.navigateSearchNext(worktree)) + await surfaceClient.send(.terminal(worktree, .navigateSearchNext)) } case .navigateSearchPrevious: @@ -859,7 +870,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.navigateSearchPrevious(worktree)) + await surfaceClient.send(.terminal(worktree, .navigateSearchPrevious)) } case .endSearch: @@ -867,7 +878,7 @@ struct AppFeature { return .none } return .run { _ in - await terminalClient.send(.endSearch(worktree)) + await surfaceClient.send(.terminal(worktree, .endSearch)) } case .settings(.repositorySettings(.delegate(.settingsChanged(let rootURL, let host)))): @@ -1196,19 +1207,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) { - command = .performBindingActionOnSurface(worktree, surfaceID: surfaceID, action: action) + } else if let surfaceID = surfaceClient.selectedSurfaceID(worktree.id) { + 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) + await surfaceClient.send(command) } case .commandPalette(.delegate(.openPullRequest(let worktreeID))): @@ -1256,7 +1267,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 +1291,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 { @@ -1288,11 +1299,12 @@ 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 .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))) } @@ -1302,29 +1314,7 @@ 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)): + 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`). @@ -1361,7 +1351,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 { @@ -1377,7 +1367,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( @@ -1387,7 +1377,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): @@ -1399,7 +1389,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 @@ -1409,10 +1399,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)))) ) @@ -1420,7 +1410,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 { @@ -1434,10 +1424,7 @@ struct AppFeature { : .send(.agentPresence(.surfacesClosed(ids))) return .merge(presenceEffect, ackEffect) - case .terminalEvent(.agentHookEventReceived(let event)): - return .send(.agentPresence(.hookEventReceived(event))) - - case .terminalEvent: + case .surfaceEvent: return .none } } @@ -1509,12 +1496,55 @@ 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 + /// `.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( + _ 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. @@ -1621,11 +1651,11 @@ struct AppFeature { let shouldRunSetupScript = state.repositories.sidebarItems[id: worktree.id]?.lifecycle == .pending return .run { _ in - await terminalClient.send( - .createTabWithInput( + await surfaceClient.send( + .create( worktree, - input: "$EDITOR", - runSetupScriptIfNew: shouldRunSetupScript + spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: shouldRunSetupScript), + placement: .tab ) ) } @@ -1986,7 +2016,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 { @@ -2000,7 +2030,7 @@ struct AppFeature { } guard let input, !input.isEmpty else { let effect = sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in - .createTab(worktree, 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) }, @@ -2012,7 +2042,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) + .create(worktree, spec: .terminal(input: input), placement: .tab, id: id) } return awaitingCompletion( effect, match: id.map { .tabInWorktree(worktreeID: worktreeID, tabID: $0) }, @@ -2057,7 +2087,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 { @@ -2077,9 +2107,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, 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) }, @@ -2152,12 +2185,12 @@ 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( - .runBlockingScript(worktree, kind: .script(definition), script: definition.command) + await surfaceClient.send( + .terminal(worktree, .runBlockingScript(kind: .script(definition), script: definition.command)) ) } } @@ -2187,9 +2220,9 @@ struct AppFeature { ) return .none } - let terminalClient = terminalClient + let surfaceClient = surfaceClient return .run { _ in - await terminalClient.send(.stopScript(worktree, definitionID: scriptID)) + await surfaceClient.send(.terminal(worktree, .stopScript(definitionID: scriptID))) } } @@ -2405,15 +2438,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 @@ -2422,7 +2455,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 } @@ -2501,9 +2534,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() } @@ -2732,7 +2765,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") @@ -2756,7 +2789,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") @@ -2811,7 +2844,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/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/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift similarity index 88% rename from supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift rename to supacode/Features/Terminal/BusinessLogic/WorktreeSurfaceManager.swift index ce211a9db..cba92436e 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,15 +95,15 @@ 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) 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 } } @@ -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): @@ -137,12 +137,16 @@ final class WorktreeTerminalManager { 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, socketServer: AgentHookSocketServer? = nil, clock: C = ContinuousClock(), - eventBufferCap: Int = WorktreeTerminalManager.defaultEventBufferCap, + eventBufferCap: Int = WorktreeSurfaceManager.defaultEventBufferCap, ) { self.eventBufferCap = eventBufferCap self.runtime = runtime @@ -230,7 +234,7 @@ final class WorktreeTerminalManager { } private func applyHookEvent(_ event: AgentHookEvent) { - emit(.agentHookEventReceived(event)) + emit(.terminal(.agentHookEventReceived(event))) } #if DEBUG @@ -262,37 +266,27 @@ final class WorktreeTerminalManager { return state.listSurfaces(tabID: terminalTabID) } - func handleCommand(_ command: TerminalClient.Command) { - if handleTabCommand(command) { - return - } - if handleBindingActionCommand(command) { + func handleCommand(_ command: SurfaceClient.Command) { + // 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 { + terminalCommands.handle(surfaceCommand, in: worktree) return } - if handleSearchCommand(command) { + if handleTabCommand(command) { return } handleManagementCommand(command) } // 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 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 .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) - 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): @@ -315,27 +309,6 @@ 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): - let terminal = state(for: worktree) - terminal.selectTab(tabID) - let ghosttyDirection: GhosttySplitAction.NewDirection = direction == .vertical ? .down : .right - let resolvedInput = BlockingScriptRunner.makeCommandInput(script: input ?? "") - let splitSucceeded = terminal.performSplitAction( - .newSplit(direction: ghosttyDirection), - for: surfaceID, - newSurfaceID: id, - initialInput: resolvedInput - ) - 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 { @@ -361,44 +334,60 @@ 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, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, - .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, - .performBindingActionOnSurface, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, - .destroyTab, .destroySurface, .setImagePasteAgents, .prune, .setNotificationsEnabled, .setSelectedWorktreeID, - .refreshTabBarVisibility, .beginTabRename: - return false + /// 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 + } } - 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, .createTabWithInput, .ensureInitialTab, .stopRunScript, .stopScript, - .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, - .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, - .focusSurface, .splitSurface, .destroyTab, .destroySurface, .prune, .setNotificationsEnabled, - .setSelectedWorktreeID, .refreshTabBarVisibility, .beginTabRename: - return false + /// 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 } - return true } private func setImagePasteAgents(_ agents: Set, onSurfaceID surfaceID: UUID) { @@ -407,7 +396,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) @@ -429,19 +418,19 @@ 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, - .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 .create, .ensureInitialTab, .closeFocusedTab, .closeFocusedSurface, .selectTab, + .selectTabAtIndex, .focusSurface, .destroyTab, .destroySurface, + .beginTabRename, .terminal: assertionFailure("Unhandled terminal command reached management handler: \(command)") } } - 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 @@ -489,7 +478,7 @@ final class WorktreeTerminalManager { func state( for worktree: Worktree, runSetupScriptIfNew: () -> Bool = { false } - ) -> WorktreeTerminalState { + ) -> WorktreeSurfaceState { if let existing = states[worktree.id] { if runSetupScriptIfNew() { existing.enableSetupScriptIfNeeded() @@ -506,7 +495,7 @@ final class WorktreeTerminalManager { return existing } let runSetupScript = runSetupScriptIfNew() - let state = WorktreeTerminalState( + let state = WorktreeSurfaceState( runtime: runtime, worktree: worktree, runSetupScript: runSetupScript @@ -567,11 +556,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 @@ -582,7 +572,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)) @@ -639,10 +629,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)) } @@ -680,7 +670,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 [] } @@ -843,7 +833,7 @@ final class WorktreeTerminalManager { states[worktreeID]?.allSurfaceIDs ?? [] } - func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeTerminalState? { + func stateIfExists(for worktreeID: Worktree.ID) -> WorktreeSurfaceState? { states[worktreeID] } @@ -859,7 +849,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 @@ -1147,7 +1137,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 @@ -1173,7 +1163,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). @@ -1196,7 +1186,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 { @@ -1204,7 +1194,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 @@ -1216,7 +1206,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) @@ -1240,7 +1230,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): @@ -1278,26 +1268,13 @@ final class WorktreeTerminalManager { let previous = lastEmittedHasAnyTerminalSurface ?? false guard hasAny != previous else { return } lastEmittedHasAnyTerminalSurface = hasAny - emit(.terminalHasAnySurfaceChanged(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: (WorktreeTerminalState) -> 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) + emit(.terminal(.hasAnySurfaceChanged(hasAny: hasAny))) } /// 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) diff --git a/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift b/supacode/Features/Terminal/Models/SurfaceDragCoordinator.swift new file mode 100644 index 000000000..b6639c0da --- /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 `WorktreeSurfaceState` 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/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/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 new file mode 100644 index 000000000..09a0e5458 --- /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`. 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 { + 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/supacode/Features/Terminal/Models/SurfaceView.swift b/supacode/Features/Terminal/Models/SurfaceView.swift new file mode 100644 index 000000000..475359942 --- /dev/null +++ b/supacode/Features/Terminal/Models/SurfaceView.swift @@ -0,0 +1,127 @@ +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* +/// 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 + + /// 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 + /// 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)) + } + + 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 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. +/// Adding a case forces every `switch` on `content` to handle it. +enum SurfaceContent { + case terminal(GhosttySurfaceView) +} 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/TerminalSurfaceCommand.swift b/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift new file mode 100644 index 000000000..1fbd60402 --- /dev/null +++ b/supacode/Features/Terminal/Models/TerminalSurfaceCommand.swift @@ -0,0 +1,36 @@ +import Foundation + +/// 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 +/// 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 `SurfaceClient.Event.terminal`. Mirror of +/// `TerminalSurfaceCommand` on the event side: neutral lifecycle / projection +/// events stay top-level on `SurfaceClient.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/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift b/supacode/Features/Terminal/Models/TerminalSurfaceStore.swift new file mode 100644 index 000000000..3753a0933 --- /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. +/// +/// `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 +/// 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/WorktreeSurfaceState.swift b/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift index ab330448f..49afbf6d7 100644 --- a/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift +++ b/supacode/Features/Terminal/Models/WorktreeSurfaceState.swift @@ -1,10 +1,2596 @@ +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() { + 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 = makeSurface(from: tabSnapshot.layout.firstLeaf, tabId: tabId, context: context) + 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: SurfaceView, + tabId: TerminalTabID + ) { + guard case .split(let split) = node else { return } + + let direction: SplitTree.NewDirection = + split.direction == .horizontal ? .right : .down + + guard + let newSurface = createRestorationSplit( + at: anchor, + direction: direction, + ratio: split.ratio, + leaf: split.right.firstLeaf, + tabId: tabId, + ) + 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: SurfaceView, + direction: SplitTree.NewDirection, + ratio: Double, + leaf: TerminalLayoutSnapshot.SurfaceSnapshot, + tabId: TerminalTabID + ) -> SurfaceView? { + guard var tree = trees[tabId] else { return nil } + 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)") + 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 + } + + 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 65b4045db..000000000 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ /dev/null @@ -1,2731 +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 - } - - private struct SurfaceLaunchMetadata { - let usesZmx: Bool - let context: ghostty_surface_context_e - } - - let tabManager: TerminalTabManager - 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] = [:] - @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] = [:] - // 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` - /// / `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?] = [:] - 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 - /// 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 - @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 - /// 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. - 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 - 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.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 - } - - 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 { isRunningProgressState($0.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) - focusSurface(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 = 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: 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 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 { - cleanupBlockingScriptLaunchDirectory(at: launchDirectory) - } - reportBlockingScriptLaunchFailure(kind, "Failed to create \(kind.tabTitle) tab for worktree \(worktree.id)") - return nil - } - if let launchDirectory { - 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 surface = tree.root?.leftmostLeaf() { - focusSurface(surface, 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 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 - } - } - } - 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) - 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) - } - focusSurface(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): - 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 } - 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) - focusSurface(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) - } - 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): - return .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( - id: view.id, - workingDirectory: view.bridge.state.pwd, - agents: agentsBySurface[view.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() { - let firstLeafPwd = tabSnapshot.layout.firstLeaf.workingDirectory - let workingDir = firstLeafPwd.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: tabSnapshot.layout.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. - let rightPwd = split.right.firstLeaf.workingDirectory - let rightWorkingDir = rightPwd.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: split.right.firstLeaf.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 - } - } - - 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 - // 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 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, - 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 = 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: 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] = SurfaceLaunchMetadata(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) - } - - 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? - } - - 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 surface = tree.visibleLeaves().first { - 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]) - } - - /// 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) - let leafIDs = tree.leaves().map(\.id) - for surface in tree.leaves() { - surface.closeSurface() - cleanupSurfaceState(for: surface.id) - } - 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 }) - { - 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( - 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 { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } - return - } - guard let node = tree.find(id: view.id) else { - view.closeSurface() - cleanupSurfaceState(for: view.id) - if killZmxSession { - 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 { - killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) - } - if newTree.isEmpty { - trees.removeValue(forKey: tabId) - focusedSurfaceIdByTab.removeValue(forKey: tabId) - 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 { - focusSurface(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 - { - focusSurface(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 - } - - #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/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 865a82e1f..b83e47981 100644 --- a/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/supacode/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -1,12 +1,11 @@ import AppKit import SwiftUI -import UniformTypeIdentifiers struct TerminalSplitTreeView: View { - let tree: SplitTree - // Owns the per-surface `WorktreeSurfaceState` map; leaves resolve their + let tree: SplitTree + // 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? @@ -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( @@ -46,15 +31,15 @@ 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 terminalState: WorktreeSurfaceState let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (Operation) -> Void @@ -62,14 +47,18 @@ 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, + dragCoordinator: terminalState.dragCoordinator, + action: action + ) + } case .split(let split): let splitViewDirection: SplitView.Direction = switch split.direction { @@ -115,14 +104,13 @@ 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) + 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. @@ -131,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 { @@ -291,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. @@ -338,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 @@ -370,8 +218,8 @@ 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 terminalState: WorktreeTerminalState + let tree: SplitTree + let terminalState: WorktreeSurfaceState let activeSurfaceID: UUID? let unfocusedSplitOverlay: (fill: Color?, opacity: Double) let action: (TerminalSplitTreeView.Operation) -> Void @@ -400,11 +248,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 +273,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/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index 19b36edb3..889f900df 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. @@ -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 @@ -112,12 +118,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/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceView.swift index 1dc22cd29..0e855002c 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? @@ -130,23 +132,6 @@ final class GhosttySurfaceView: NSView, Identifiable { } } } - 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] = [ @@ -213,7 +198,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 +223,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() @@ -382,46 +366,16 @@ final class GhosttySurfaceView: NSView, Identifiable { 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() diff --git a/supacodeTests/AgentBusyStateTests.swift b/supacodeTests/AgentBusyStateTests.swift index 2c4664926..09e0acfb4 100644 --- a/supacodeTests/AgentBusyStateTests.swift +++ b/supacodeTests/AgentBusyStateTests.swift @@ -57,11 +57,11 @@ struct AgentBusyStateTests { } @Test func multipleSurfacesBusyInDifferentTabs() { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + let (manager, presence) = WorktreeSurfaceManager.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") @@ -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 @@ -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,19 +431,19 @@ 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 } 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) } 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 66c732ace..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 @@ -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 { @@ -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/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/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 9cd423add..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")))) @@ -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")) ] ) } @@ -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,16 +142,16 @@ 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")))) 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 { @@ -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")))) @@ -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")) ] ) } @@ -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,16 +346,16 @@ 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")))) 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 { @@ -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 fb077500e..2997803ca 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) } } } @@ -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 @@ -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) } } } @@ -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) @@ -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) } } } @@ -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 @@ -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) } } } @@ -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) @@ -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) } } } @@ -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) @@ -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) } } } @@ -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) @@ -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) } } } @@ -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) @@ -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) } } } @@ -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) @@ -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) } } } @@ -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 @@ -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 @@ -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) @@ -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 @@ -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) @@ -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) } } } @@ -1267,14 +1267,14 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo hello", runSetupScriptIfNew: false, id: nil) + .create(worktree, spec: .terminal(input: "echo hello"), placement: .tab, id: nil) ) ) } @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) } } } @@ -1302,7 +1302,7 @@ struct AppFeatureDeeplinkTests { } #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo hello", runSetupScriptIfNew: false, id: nil) + .create(worktree, spec: .terminal(input: "echo hello"), placement: .tab, id: nil) ) ) await store.finish() @@ -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) } } } @@ -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) @@ -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) } } } @@ -2081,7 +2081,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo test", runSetupScriptIfNew: false, id: nil) + .create(worktree, spec: .terminal(input: "echo test"), placement: .tab, id: nil) ) ) } @@ -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) } } } @@ -2506,7 +2506,7 @@ struct AppFeatureDeeplinkTests { #expect(store.state.deeplinkInputConfirmation == nil) #expect( sent.value.contains( - .createTabWithInput(worktree, input: "echo test", runSetupScriptIfNew: false, id: nil) + .create(worktree, spec: .terminal(input: "echo test"), placement: .tab, id: nil) ) ) } @@ -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 ace32cc30..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 == [ - .createTabWithInput(context.worktree, 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 == [ - .createTabWithInput(context.worktree, input: "$EDITOR", runSetupScriptIfNew: true) + .create(context.worktree, spec: .terminal(input: "$EDITOR", runSetupScriptIfNew: true), placement: .tab) ] ) await store.finish() @@ -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 fd2ee03a8..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) } } } @@ -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 } @@ -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,12 +177,14 @@ struct AppFeatureRunScriptTests { } await store.send( - .terminalEvent( - .blockingScriptCompleted( - worktreeID: worktree.id, - kind: .script(definition), - exitCode: 0, - tabId: nil + .surfaceEvent( + .terminal( + .blockingScriptCompleted( + worktreeID: worktree.id, + kind: .script(definition), + exitCode: 0, + tabId: nil + ) ) ) ) @@ -208,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 @@ -237,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 @@ -247,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, @@ -259,7 +261,7 @@ struct AppFeatureRunScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -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 } @@ -279,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, @@ -288,7 +290,7 @@ struct AppFeatureRunScriptTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } @@ -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 } @@ -376,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() @@ -385,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) } } } @@ -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 } @@ -467,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) } } } @@ -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) @@ -499,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) } } } @@ -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) @@ -528,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 aeedc45ae..dffbbad19 100644 --- a/supacodeTests/AppFeatureSplitTerminalTests.swift +++ b/supacodeTests/AppFeatureSplitTerminalTests.swift @@ -21,9 +21,11 @@ struct AppFeatureSplitTerminalTests { } @Test(.dependencies, arguments: TerminalSplitMenuDirection.allCases) - func splitTerminalForwardsGhosttyBinding(direction: TerminalSplitMenuDirection) async { + func splitTerminalDispatchesAdjacentCreateAtFocusedSurface(direction: TerminalSplitMenuDirection) async { let worktree = makeWorktree() - let sent = LockIsolated<[TerminalClient.Command]>([]) + let tabID = TerminalTabID() + let surfaceID = UUID() + let sent = LockIsolated<[SurfaceClient.Command]>([]) let store = TestStore( initialState: AppFeature.State( repositories: makeRepositoriesState(worktree: worktree), @@ -32,14 +34,42 @@ struct AppFeatureSplitTerminalTests { ) { AppFeature() } withDependencies: { - $0.terminalClient.send = { command in + $0.surfaceClient.selectedTabID = { _ in tabID } + $0.surfaceClient.selectedSurfaceID = { _ in surfaceID } + $0.surfaceClient.send = { command in sent.withValue { $0.append(command) } } } await store.send(.splitTerminal(direction)) await store.finish() - #expect(sent.value == [.performBindingAction(worktree, action: 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 { @@ -51,8 +81,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 c6b6fbaf9..c60c99a24 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,20 +25,20 @@ 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(.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 $0.repositories.applyPostReduceCacheRecomputes([.sidebarStructure, .selectedWorktreeSlice]) } await store.finish() - #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: true, id: nil)]) + #expect(sent.value == [.create(worktree, spec: .terminal(runSetupScriptIfNew: true), placement: .tab, id: nil)]) } @Test(.dependencies) func newTerminalWithoutSetupScriptDoesNotConsume() async { @@ -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,14 +57,14 @@ 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.finish() - #expect(sent.value == [.createTab(worktree, runSetupScriptIfNew: false, id: nil)]) + #expect(sent.value == [.create(worktree, spec: .terminal(runSetupScriptIfNew: false), placement: .tab, id: nil)]) } @Test(.dependencies) func tabCreatedDoesNotConsumeSetupScript() async { @@ -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(.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/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/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) } } diff --git a/supacodeTests/RepositoriesFeatureSidebarTests.swift b/supacodeTests/RepositoriesFeatureSidebarTests.swift index 529e0dbf5..e819154c0 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 ) ], @@ -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 a3f8d01ae..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,20 +210,20 @@ struct SplitTreeTests { } private func makeWorktreeFixture(preserveZoomOnNavigation: Bool) -> WorktreeFixture { - let state = WorktreeTerminalState( + let state = WorktreeSurfaceState( runtime: GhosttyRuntime(), worktree: makeWorktree(), 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 ) } @@ -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/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/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/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/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 1e420ec00..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. @@ -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 @@ -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 } @@ -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 8fe1ee87f..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,13 +81,13 @@ struct WorktreeTerminalManagerReaperTests { listSessionsWithClients: { listing.value } ) } operation: { - WorktreeTerminalManager(runtime: GhosttyRuntime()) + WorktreeSurfaceManager(runtime: GhosttyRuntime()) } 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/WorktreeSurfaceManagerTests.swift similarity index 85% rename from supacodeTests/WorktreeTerminalManagerTests.swift rename to supacodeTests/WorktreeSurfaceManagerTests.swift index 210bfa04f..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) @@ -105,24 +105,24 @@ 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 { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let state = manager.state(for: worktree) 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,14 +132,14 @@ 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() { 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,14 +149,14 @@ 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(.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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and socket server") return @@ -172,13 +172,13 @@ 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(.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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -203,13 +203,13 @@ 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(.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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -231,13 +231,13 @@ 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(.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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -264,13 +264,13 @@ 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(.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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -292,13 +292,13 @@ 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(.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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab and surface") return @@ -319,11 +319,11 @@ 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") - 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,16 +335,16 @@ 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) } @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") @@ -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]) @@ -375,18 +375,18 @@ 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) 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]) @@ -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,19 +420,19 @@ 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) 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]) @@ -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,17 +455,17 @@ 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() - 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) @@ -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() @@ -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) } @@ -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,11 +588,11 @@ struct WorktreeTerminalManagerTests { } @Test func stopScriptWithoutTerminalStateDoesNotMintOne() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(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) } @@ -602,14 +602,14 @@ 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(.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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking script tab") return @@ -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) @@ -662,19 +662,19 @@ 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() { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let state = manager.state(for: worktree) 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 @@ -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,11 +930,11 @@ 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), - 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 @@ -951,13 +951,13 @@ 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 } 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 @@ -970,11 +970,11 @@ 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), - 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 @@ -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() @@ -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 @@ -1034,15 +1036,15 @@ struct WorktreeTerminalManagerTests { return nil } #expect(surface.id == surfaceID) - return surface + return surface.terminalForTesting } @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), - 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 @@ -1060,11 +1062,11 @@ 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), - 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 +1112,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 +1211,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 +1234,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() @@ -1248,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 @@ -1309,7 +1311,7 @@ struct WorktreeTerminalManagerTests { listSessionsWithClients: { [] } ) } operation: { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) _ = manager.state(for: worktree) return manager } @@ -1355,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"), @@ -1464,7 +1466,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 +1479,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 +1505,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 +1516,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 +1535,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 +1546,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 +1576,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 +1587,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 +1617,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 +1625,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 +1641,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 +1649,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 +1667,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 +1690,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 +1698,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 +1714,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 +1724,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 +1742,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 +1752,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") { @@ -1768,9 +1770,9 @@ 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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected a blocking-script tab and surface") return @@ -1778,7 +1780,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 }) } @@ -1789,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( @@ -1801,7 +1803,7 @@ struct WorktreeTerminalManagerTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal( id: knownSurfaceID, workingDirectory: "/tmp/repo/wt-1" ) @@ -1830,12 +1832,12 @@ 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 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 @@ -1847,27 +1849,27 @@ 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() - 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, - 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 @@ -1876,25 +1878,26 @@ 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 { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) 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, - 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 @@ -1903,25 +1906,27 @@ 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 { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) 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, - 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,31 +1934,32 @@ 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 - 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)) } @Test func blockingScriptChildExitWithoutCommandFinishedIsCancellation() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) 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, - 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 @@ -1962,24 +1968,25 @@ 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 { // 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() - 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, @@ -1989,28 +1996,29 @@ struct WorktreeTerminalManagerTests { return } - surface.bridge.onChildExited?(0) + 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 { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) 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, - 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 @@ -2021,21 +2029,23 @@ 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 { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() 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 +2055,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") @@ -2056,28 +2066,31 @@ 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 } 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 { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() 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,21 +2103,22 @@ 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 { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() 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,25 +2128,26 @@ 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 { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) 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, - 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 @@ -2143,52 +2158,53 @@ 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)) } @Test func runScriptBlockingScriptTracksRunningState() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) let worktree = makeWorktree() let definition = ScriptDefinition(kind: .run, command: "echo hi") #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) } @Test func stopRunScriptClosesRunTab() { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.withPresenceHarness() 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) } @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") - 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, - 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 @@ -2206,7 +2222,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 } @@ -2226,14 +2242,14 @@ struct WorktreeTerminalManagerTests { } @Test func blockingScriptTabTitleResetsAfterFailure() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(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, - 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 @@ -2256,13 +2272,13 @@ 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(.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() + let firstSurface = state.splitTree(for: firstTabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected first blocking-script tab and surface") return @@ -2274,7 +2290,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) @@ -2283,12 +2299,12 @@ struct WorktreeTerminalManagerTests { } @Test func residualProgressReportDoesNotResurrectDirtyOnFrozenTab() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking-script tab and surface") return @@ -2307,12 +2323,12 @@ 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. - 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") @@ -2337,10 +2353,10 @@ struct WorktreeTerminalManagerTests { } @Test func selectTabWithStaleIdIsNoOp() { - let (manager, _) = WorktreeTerminalManager.withPresenceHarness() + let (manager, _) = WorktreeSurfaceManager.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 @@ -2362,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) @@ -2389,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) @@ -2419,28 +2435,28 @@ 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) 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 @@ -2458,12 +2474,12 @@ 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 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 @@ -2486,16 +2502,16 @@ 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) 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 @@ -2517,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([ @@ -2527,12 +2543,12 @@ 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 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 @@ -2566,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) @@ -2600,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() @@ -2616,7 +2632,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) } @@ -2624,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() @@ -2648,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() @@ -2664,14 +2680,14 @@ 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) } @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() @@ -2702,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() @@ -2721,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) @@ -2736,7 +2752,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) } @@ -2744,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 { @@ -2827,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 } @@ -3028,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 } @@ -3052,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) @@ -3067,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) @@ -3085,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) @@ -3097,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) @@ -3107,11 +3123,11 @@ 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() - 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) @@ -3126,15 +3142,15 @@ struct WorktreeTerminalManagerTests { } @Test func captureLayoutSnapshotExcludesCompletedBlockingScriptTabs() async { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(runtime: GhosttyRuntime()) 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, - 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 @@ -3142,7 +3158,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 @@ -3152,14 +3168,14 @@ 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 { 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 @@ -3185,12 +3201,12 @@ struct WorktreeTerminalManagerTests { } @Test func performSplitActionRefusesNewSplitOnBlockingScriptTab() { - let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let manager = WorktreeSurfaceManager(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() + let surface = state.splitTree(for: tabId).root?.leftmostLeaf().terminalForTesting else { Issue.record("Expected blocking-script tab and surface") return @@ -3203,18 +3219,18 @@ 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(), - 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")) + manager.handleCommand(.terminal(worktree, .runBlockingScript(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 @@ -3231,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) @@ -3243,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 ) ], @@ -3258,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) @@ -3270,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 ) ], @@ -3292,7 +3308,7 @@ struct WorktreeTerminalManagerTests { icon: nil, tintColor: nil, layout: .leaf( - TerminalLayoutSnapshot.SurfaceSnapshot( + TerminalLayoutSnapshot.SurfaceSnapshot.terminal( id: nil, workingDirectory: "/tmp/repo/wt-1" ) @@ -3312,15 +3328,15 @@ 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) 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 @@ -3341,15 +3357,15 @@ struct WorktreeTerminalManagerTests { } @Test func agentPresenceOnTabBLeavesTabAAgentsUnchanged() { - let (manager, presence) = WorktreeTerminalManager.withPresenceHarness() + let (manager, presence) = WorktreeSurfaceManager.withPresenceHarness() let worktree = makeWorktree() let state = manager.state(for: worktree) 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 @@ -3374,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, @@ -3389,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, @@ -3438,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/WorktreeSurfaceStateDropTests.swift b/supacodeTests/WorktreeSurfaceStateDropTests.swift new file mode 100644 index 000000000..04a2bfded --- /dev/null +++ b/supacodeTests/WorktreeSurfaceStateDropTests.swift @@ -0,0 +1,92 @@ +import Foundation +import GhosttyKit +import Testing + +@testable import supacode + +@MainActor +struct WorktreeSurfaceStateDropTests { + private struct SplitTab { + let state: WorktreeSurfaceState + 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 = WorktreeSurfaceManager(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"), + ) + } +}