import AppKit import CoreGraphics import Foundation import GhosttyKit import ProwlCLIShared enum SplitCreationError: Error, Equatable, Sendable { case anchorNotFound(UUID) case insertionFailed } extension WorktreeTerminalState { /// Types `text` into an agent pane and presses Enter. `AgentComposerProfile` decides, per runtime /// and purpose, whether Enter waits for the paste echo. `isLive` is checked again before Enter. func submitAgentLine( _ text: String, surfaceID: UUID, purpose: AgentLinePurpose, isLive: @escaping @MainActor () -> Bool = { true } ) async -> AgentLineSubmission { guard isLive(), let surface = surfaces[surfaceID] else { return .notInserted } if purpose == .dispatch, dispatchInputProtection(surfaceID: surfaceID) != nil { return .notInserted } let agent = surfaceAgentStates[surfaceID]?.detectedAgent guard let profile = agent.flatMap(AgentComposerProfile.init(agent:)), let confirmation = profile.pasteConfirmation(for: purpose), confirmation == .required || composerContents(of: surface, profile: profile) == "" else { return submitDirectly(text, surfaceID: surfaceID, isLive: isLive) } let delivery = AgentPromptDelivery( observe: { [weak self, weak surface] in guard isLive(), let self, let surface, self.surfaces[surfaceID] === surface, self.surfaceAgentStates[surfaceID]?.detectedAgent == agent else { return nil } return .init( composer: self.composerContents(of: surface, profile: profile), editingRevision: surface.lastEditingAt, hasMarkedText: surface.hasMarkedText()) }, insert: { [weak self] in self?.insertCommittedText($0, in: surfaceID) == true }, submit: { [weak self] in isLive() && self?.submitLine(in: surfaceID) == true }, profile: profile) return await delivery.deliver(text) } private func submitDirectly( _ text: String, surfaceID: UUID, isLive: @MainActor () -> Bool ) -> AgentLineSubmission { guard isLive(), !Task.isCancelled, insertCommittedText(text, in: surfaceID) else { return .notInserted } return submitLine(in: surfaceID) ? .submitted : .notSubmitted } private func composerContents(of surface: GhosttySurfaceView, profile: AgentComposerProfile) -> String? { surface.readActiveContentsForCLI().flatMap { profile.contents(in: AgentScreenSnapshot(text: $0)) } } func dispatchInputProtection(surfaceID: UUID) -> String? { guard let surface = surfaces[surfaceID] else { return "The target terminal is no longer available." } if let refusal = AgentDispatchInputProtection.refusal( hasMarkedText: surface.hasMarkedText(), lastEditingAt: surface.lastEditingAt, now: ProcessInfo.processInfo.systemUptime) { return refusal } let agent = surfaceAgentStates[surfaceID]?.detectedAgent if let profile = agent.flatMap(AgentComposerProfile.init(agent:)) { guard let draft = composerContents(of: surface, profile: profile) else { return "The \(profile.name) input area could not be verified. Check Host before dispatching." } guard draft.isEmpty else { return "\(profile.name) has an existing draft or attachment. Clear it on Host before dispatching." } } if agent == .codex { guard let text = surface.readStyledSnapshotForCLI(), CodexScreenProfile.composerHasNoDraft(styledSnapshot: text) else { return "Codex has a draft, attachment, or an unrecognized input area. Check Host before dispatching." } } return nil } func confirmCloseIfNeeded( tabIds: [TerminalTabID], mode: TerminalCloseConfirmationMode ) -> Bool { let surfaceIDs = tabIds.flatMap { tabId in trees[tabId]?.leaves().map(\.id) ?? [] } return confirmCloseIfNeeded(surfaceIDs: surfaceIDs, mode: mode) } func confirmCloseIfNeeded( surfaceIDs: [UUID], mode: TerminalCloseConfirmationMode ) -> Bool { guard !surfaceIDs.isEmpty else { return true } switch mode { case .skip: return true case .prompt(let target): let candidates = closeProtectionCandidates(surfaceIDs: surfaceIDs) let decision = TerminalCloseConfirmationPolicy.decision(for: candidates) guard decision.requiresConfirmation else { return true } return presentCloseConfirmation(target: target, decision: decision) } } func closeProtectionCandidates(surfaceIDs: [UUID]) -> [TerminalCloseProtectionCandidate] { let now = Date() let uptime = ProcessInfo.processInfo.systemUptime return surfaceIDs.map { surfaceID in let agentState = surfaceAgentStates[surfaceID] let runningDuration = surfaceRunningStartedAtById[surfaceID].map { now.timeIntervalSince($0) } return TerminalCloseProtectionCandidate( hasAgent: agentState?.detectedAgent != nil, agentDisplayState: agentState?.displayState, commandRunningDuration: runningDuration, editingAge: surfaces[surfaceID]?.lastEditingAt.map { uptime - $0 }, hasMarkedText: surfaces[surfaceID]?.hasMarkedText() ?? false ) } } func presentCloseConfirmation( target: TerminalCloseConfirmationTarget, decision: TerminalCloseConfirmationDecision ) -> Bool { let confirmed = TerminalCloseConfirmationGate.run { let alert = NSAlert() alert.messageText = localizedMessageText(for: target) alert.informativeText = closeConfirmationMessage(for: decision) alert.alertStyle = .warning alert.addButton(withTitle: localizedConfirmButtonTitle(for: target)) alert.addButton(withTitle: String(localized: "Cancel")) return alert.runModal() == .alertFirstButtonReturn } return confirmed ?? false } func closeConfirmationMessage(for decision: TerminalCloseConfirmationDecision) -> String { TerminalCloseConfirmationPolicy.informativeMessage(for: decision, worktreeName: worktree.name) } private func localizedMessageText(for target: TerminalCloseConfirmationTarget) -> String { switch target { case .pane: return String(localized: "Close Terminal Pane?") case .tab: return String(localized: "Close Terminal Tab?") case .tabs(let count): if count == 1 { return String(localized: "Close Terminal Tab?") } else { return String(localized: "Close Terminal Tabs?") } } } private func localizedConfirmButtonTitle(for target: TerminalCloseConfirmationTarget) -> String { switch target { case .pane: return String(localized: "Close Pane") case .tab: return String(localized: "Close Tab") case .tabs(let count): if count == 1 { return String(localized: "Close Tab") } else { return String(localized: "Close Tabs") } } } func splitTree( for tabId: TerminalTabID, inheritingFromSurfaceId: UUID? = nil, initialInput: String? = nil, workingDirectoryOverride: URL? = nil, context: ghostty_surface_context_e = GHOSTTY_SURFACE_CONTEXT_TAB, additionalEnvironment: [String: String] = [:], defersSurfaceCreation: Bool = false ) -> SplitTree { guard tabManager.tabs.contains(where: { $0.id == tabId }) else { return SplitTree() } if let existing = trees[tabId] { return existing } let surface = createSurface( tabId: tabId, initialInput: initialInput, inheritingFromSurfaceId: inheritingFromSurfaceId, workingDirectoryOverride: workingDirectoryOverride, context: context, additionalEnvironment: additionalEnvironment, defersSurfaceCreation: defersSurfaceCreation ) let tree = SplitTree(view: surface) trees[tabId] = tree focusedSurfaceIdByTab[tabId] = surface.id return tree } /// Splits an explicit anchor surface and returns the new pane identity. /// The anchor is resolved directly; callers never need to mutate UI focus before splitting. @discardableResult func createSplit( of anchorSurfaceID: UUID, direction: UserCustomSplitDirection, initialInput: String?, workingDirectoryOverride: URL? = nil, additionalEnvironment: [String: String] = [:], focusing: Bool = true, defersSurfaceCreation: Bool = false ) -> Result { guard let tabID = tabId(containing: anchorSurfaceID), let tree = trees[tabID], let anchorSurface = surfaces[anchorSurfaceID] else { return .failure(.anchorNotFound(anchorSurfaceID)) } let newSurface = createSurface( tabId: tabID, initialInput: initialInput.flatMap { runScriptInput($0) }, inheritingFromSurfaceId: anchorSurfaceID, workingDirectoryOverride: workingDirectoryOverride, context: GHOSTTY_SURFACE_CONTEXT_SPLIT, additionalEnvironment: additionalEnvironment, defersSurfaceCreation: defersSurfaceCreation ) do { let newTree = try tree.inserting( view: newSurface, at: anchorSurface, direction: mapUserSplitDirection(direction) ) updateTree(newTree, for: tabID) if isCanvasManaged { newSurface.setOcclusion(true) } if focusing { focusSurface(newSurface, in: tabID) } _ = registerTargetHandle(for: newSurface.id) return .success(newSurface.id) } catch { newSurface.closeSurface() forgetSurface(newSurface.id) return .failure(.insertionFailed) } } /// Splits the currently focused surface and seeds the new pane with `initialInput`. /// Returns the new surface id, or nil if the split could not be created. @discardableResult func createSplitOnFocusedSurface( direction: UserCustomSplitDirection, initialInput: String, additionalEnvironment: [String: String] = [:] ) -> UUID? { guard let tabID = tabManager.selectedTabId, let anchorSurfaceID = focusedSurfaceIdByTab[tabID] else { return nil } return try? createSplit( of: anchorSurfaceID, direction: direction, initialInput: initialInput, additionalEnvironment: additionalEnvironment, focusing: true ).get() } /// Returns the focused surface id for a given tab, if any. func focusedSurfaceId(in tabId: TerminalTabID) -> UUID? { focusedSurfaceIdByTab[tabId] } func activeSurfaceID(for tabId: TerminalTabID) -> UUID? { focusedSurfaceIdByTab[tabId] } /// Marks a surface so that its next successful `command_finished` event (exit 0) /// will trigger a one-shot close of that surface. func markSurfaceForAutoClose(_ surfaceId: UUID) { autoCloseSurfaceIds.insert(surfaceId) } func isMarkedForAutoClose(_ surfaceId: UUID) -> Bool { autoCloseSurfaceIds.contains(surfaceId) } /// Records the user-facing Custom Command name associated with a freshly created surface, /// so a success toast can be emitted when that surface's next command exits with code 0. func markSurfaceForCustomCommand(_ surfaceId: UUID, name: String) { pendingCustomCommands[surfaceId] = name } /// Pin a Custom Command's configured icon onto its host tab so the /// auto-detector can't swap it out when the script's OSC-2 title /// matches a known command. Yields to a user-set icon lock — manual /// picker selections always win. func applyCustomCommandIcon(_ icon: String, surfaceId: UUID) { let trimmed = icon.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } guard let tabId = tabId(containing: surfaceId) else { return } tabManager.setScriptIcon(tabId, icon: trimmed) } // Short delay lets the user see the final output before the pane disappears. static let autoCloseDelay: Duration = .milliseconds(800) func scheduleAutoClose(surfaceId: UUID) { Task { [weak self] in try? await Task.sleep(for: Self.autoCloseDelay) guard let self else { return } guard let view = self.surfaces[surfaceId] else { return } // Close-on-success is the command's own outcome, not something to undo. self.handleCloseRequest(for: view, processAlive: false, retainForUndo: false) } } func performSplitAction(_ action: GhosttySplitAction, for surfaceId: UUID) -> 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): switch createSplit( of: surfaceId, direction: mapGhosttySplitDirection(direction), initialInput: nil, focusing: true ) { case .success: return true case .failure: 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 { tree = tree.settingZoomed(nil) trees[tabId] = tree } 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 } 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) case .toggleZoom(let surfaceId): _ = performSplitAction(.toggleSplitZoom, for: surfaceId) case .close(let surfaceId): // Same protection as the tab bar's close button; ⌘W reaches the same // pane through Ghostty's `close_surface` binding instead. _ = closeSurface(id: surfaceId) } } func setAllSurfacesOccluded() { for surface in surfaces.values { surface.setOcclusion(false) surface.focusDidChange(false) } } func closeAllSurfaces() { onSurfacesReset?() for tab in tabManager.tabs { unregisterTargetHandle(for: tab.id) } // `closeSurface()` may synchronously re-enter `forgetSurface`; snapshot the // values and let the guarded cleanup seam make each lifecycle exactly once. for surface in Array(surfaces.values) { surface.closeSurface() forgetSurface(surface.id) } surfaces.removeAll() launchProfilesBySurface.removeAll() trees.removeAll() focusedSurfaceIdByTab.removeAll() cleanupAllAgentDetectionState() tabIsRunningById.removeAll() tabAgentBusyById.removeAll() tabAgentBlockedById.removeAll() boundDirectoryTabIDs.removeAll() autoCloseSurfaceIds.removeAll() pendingCustomCommands.removeAll() setRunScriptTabId(nil) tabManager.closeAll() } func createSurface( tabId: TerminalTabID, initialInput: String?, inheritingFromSurfaceId: UUID?, workingDirectoryOverride: URL? = nil, context: ghostty_surface_context_e, additionalEnvironment: [String: String] = [:], defersSurfaceCreation: Bool = false ) -> GhosttySurfaceView { let inherited = inheritedSurfaceConfig(fromSurfaceId: inheritingFromSurfaceId, context: context) let resolvedFontSize = Self.resolvedFontSizeForNewSurface( defaultFontSize: defaultFontSize, inheritedFontSize: inherited.fontSize, context: context ) let view = GhosttySurfaceView( runtime: runtime, workingDirectory: workingDirectoryOverride ?? inherited.workingDirectory ?? worktree.workingDirectory, initialInput: initialInput, fontSize: resolvedFontSize, context: context, environment: worktree.scriptEnvironment.merging(additionalEnvironment) { _, patched in patched }, skipsSurfaceCreationForTesting: skipsSurfaceCreationForTesting, failsSurfaceCreationForTesting: failsSurfaceCreationForTesting, defersSurfaceCreation: defersSurfaceCreation ) configureBridgeCallbacks(for: view, tabId: tabId) configureSurfaceCallbacks(for: view, tabId: tabId) surfaces[view.id] = view if !defersSurfaceCreation, initialInput?.isEmpty == false { wakeAgentDetection(for: view, tabId: tabId) } return view } func configureBridgeCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { view.bridge.onChildExited = nil view.bridge.onUndo = { [weak self] in self?.onUndoRequested?() ?? false } view.bridge.onRedo = { [weak self] in self?.onRedoRequested?() ?? false } view.bridge.onTitleChange = { [weak self, weak view] title in guard let self, let view else { return } if self.focusedSurfaceIdByTab[tabId] == view.id, self.tabManager.updateTitle(tabId, title: title) { // The tab's display title moved: refresh the whole tab because it is // the title fallback for panes without their own title. self.refreshAgentEntriesForTitleChange(in: tabId) } else { // Unfocused pane, or the tab title didn't visibly change (custom title // mask / no-op): only this pane's own title moved. self.refreshAgentEntryForTitleChange(surfaceID: view.id, in: tabId) } self.noteTitleForCommandDetection(title, surfaceId: view.id, tabId: tabId) } view.bridge.onSplitAction = { [weak self, weak view] action in guard let self, let 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 } return self.createTab(inheritingFromSurfaceId: view.id) != nil } view.bridge.onCloseTab = { [weak self] _ in guard let self else { return false } return self.closeTab(tabId) } view.bridge.onGotoTab = { [weak self] target in guard let self else { return false } return self.handleGotoTabRequest(target) } view.bridge.onCommandPaletteToggle = { [weak self] in guard let self else { return false } self.onCommandPaletteToggle?() return true } view.bridge.onProgressReport = { [weak self] _ in guard let self else { return } self.updateRunningState(for: tabId) } view.bridge.onProgramStatus = { [weak self, weak view] report in guard let self, let view else { return } self.handleProgramStatus(report, surfaceID: view.id) } view.bridge.onDesktopNotification = { [weak self, weak view] title, body in guard let self, let view else { return } self.appendNotification(title: title, body: body, surfaceId: view.id) } view.bridge.onCommandFinished = { [weak self, weak view] exitCode, durationNs in guard let self, let view else { return } self.handleCommandFinished(exitCode: exitCode, durationNs: durationNs, surfaceId: view.id) } view.bridge.onCloseRequest = { [weak self, weak view] processAlive in guard let self, let view else { return } self.handleCloseRequest(for: view, processAlive: processAlive) } view.bridge.onPromptTitle = { [weak self] promptType in guard let self else { return } self.handlePromptTitle(promptType, tabId: tabId) } } func configureSurfaceCallbacks(for view: GhosttySurfaceView, tabId: TerminalTabID) { view.onFocusChange = { [weak self, weak view] focused in guard let self, let view, focused else { return } self.recordActiveSurface(view, in: tabId) self.emitTaskStatusIfChanged() } view.onKeyInput = { [weak self, weak view] in guard let self, let view else { return } self.agentDetectionCoordinators[view.id]?.interacted() self.recordKeyInput(forSurfaceID: view.id) self.wakeAgentDetection(for: view, tabId: tabId) self.markNotificationsRead(forSurfaceID: view.id) } view.onFontSizeShortcut = { [weak self] in guard let self else { return } self.onFontSizeAdjusted?() } } static func resolvedFontSizeForNewSurface( defaultFontSize: Float32?, inheritedFontSize: Float32?, context: ghostty_surface_context_e ) -> Float32? { if context == GHOSTTY_SURFACE_CONTEXT_SPLIT { return inheritedFontSize ?? defaultFontSize } return defaultFontSize } struct InheritedSurfaceConfig: Equatable { let workingDirectory: URL? let fontSize: Float32? } 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) } func currentFocusedSurfaceId() -> UUID? { guard let selectedTabId = tabManager.selectedTabId else { return nil } return focusedSurfaceIdByTab[selectedTabId] } func handlePromptTitle( _ promptType: ghostty_action_prompt_title_e, tabId: TerminalTabID ) { guard let surfaceId = focusedSurfaceIdByTab[tabId], let window = surfaces[surfaceId]?.window else { return } switch promptType { case GHOSTTY_PROMPT_TITLE_SURFACE, GHOSTTY_PROMPT_TITLE_TAB: // Prowl is a single-window app so there is no per-surface window title to set. // Both surface and tab title prompts are treated as tab title changes for now. // Consider removing GHOSTTY_PROMPT_TITLE_SURFACE support entirely. promptTabTitle(for: tabId, in: window) default: break } } func promptChangeTabTitle(_ tabId: TerminalTabID) { let surfaceWindow = focusedSurfaceIdByTab[tabId].flatMap { surfaces[$0]?.window } guard let window = surfaceWindow ?? NSApp.keyWindow else { return } promptTabTitle(for: tabId, in: window) } func presentIconPicker(for tabId: TerminalTabID) { guard tabManager.tabs.contains(where: { $0.id == tabId }) else { return } iconPickerTabId = tabId } func presentIconPickerForFocusedTab() { guard let tabId = tabManager.selectedTabId else { return } presentIconPicker(for: tabId) } func dismissIconPicker() { iconPickerTabId = nil } /// Default SF Symbol used for a tab when the user has not set an override. func defaultIcon(for tabId: TerminalTabID) -> String { tabId == runScriptTabId ? "play.fill" : "terminal" } /// Apply an icon change for `tabId`. Pass `nil` to clear the override and /// restore the tab's default icon. func applyIconChange(_ tabId: TerminalTabID, icon newIcon: String?) { if let newIcon, !newIcon.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { tabManager.overrideIcon(tabId, icon: newIcon) } else { tabManager.clearIconOverride(tabId) tabManager.updateIcon(tabId, icon: defaultIcon(for: tabId)) } } func promptTabTitle(for tabId: TerminalTabID, in window: NSWindow) { guard let tabIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } let alert = NSAlert() alert.messageText = String(localized: "Change Tab Title") alert.informativeText = String(localized: "Leave blank to restore the default.") alert.alertStyle = .informational let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 250, height: 24)) textField.stringValue = tabManager.tabs[tabIndex].displayTitle alert.accessoryView = textField alert.addButton(withTitle: String(localized: "OK")) alert.addButton(withTitle: String(localized: "Cancel")) alert.window.initialFirstResponder = textField alert.beginSheetModal(for: window) { [weak self] response in MainActor.assumeIsolated { guard response == .alertFirstButtonReturn else { return } guard let self else { return } let newTitle = textField.stringValue if self.tabManager.setCustomTitle(tabId, title: newTitle) { self.refreshAgentEntriesForTitleChange(in: tabId) } } } } func updateTabTitle(for tabId: TerminalTabID) { guard let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId], let title = surface.bridge.state.title else { return } if tabManager.updateTitle(tabId, title: title) { refreshAgentEntriesForTitleChange(in: tabId) } } 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) } } 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) } func recordActiveSurface(_ surface: GhosttySurfaceView, in tabId: TerminalTabID) { focusedSurfaceIdByTab[tabId] = surface.id markAgentSeen(surfaceID: surface.id) markNotificationsRead(forSurfaceID: surface.id) updateTabTitle(for: tabId) emitFocusChangedIfNeeded(surface.id) } /// Drops all per-surface bookkeeping for a surface that has been torn down, /// including any notifications it produced. A notification is keyed by its /// originating surface and is only cleared when that surface is focused or /// typed into; once the surface is gone there is no way to mark it read, so /// without dropping them here the worktree's unseen indicator (bell + Dock /// badge) would stay lit until the user manually dismisses everything. func forgetSurface(_ surfaceID: UUID) { guard surfaces[surfaceID] != nil else { return } unregisterTargetHandle(for: surfaceID) surfaces.removeValue(forKey: surfaceID) launchProfilesBySurface.removeValue(forKey: surfaceID) launchHookRegistrationsBySurface.removeValue(forKey: surfaceID) surfaceRunningStartedAtById.removeValue(forKey: surfaceID) autoCloseSurfaceIds.remove(surfaceID) pendingCustomCommands.removeValue(forKey: surfaceID) cleanupCommandDetectorState(forSurfaceId: surfaceID) cleanupAgentDetectionState(forSurfaceId: surfaceID) if let tabId = tabId(containing: surfaceID) { updateTabAgentBusyState(for: tabId) } let previousHasUnseen = hasUnseenNotification notifications = Self.prunedNotifications(from: notifications, removingSurfaceID: surfaceID) emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) onSurfaceClosed?(surfaceID) } func removeTree(for tabId: TerminalTabID) { guard let tree = trees.removeValue(forKey: tabId) else { return } for surface in tree.leaves() { surface.closeSurface() forgetSurface(surface.id) } focusedSurfaceIdByTab.removeValue(forKey: tabId) tabIsRunningById.removeValue(forKey: tabId) tabAgentBusyById.removeValue(forKey: tabId) tabAgentBlockedById.removeValue(forKey: tabId) } func tabID(containing surfaceId: UUID) -> TerminalTabID? { for (tabId, tree) in trees where tree.find(id: surfaceId) != nil { return tabId } return nil } func tabId(containing surfaceId: UUID) -> TerminalTabID? { tabID(containing: surfaceId) } func isFocusedSurface(_ surfaceId: UUID) -> Bool { guard let selectedTabId = tabManager.selectedTabId else { return false } return focusedSurfaceIdByTab[selectedTabId] == surfaceId } /// Whether the terminal surface occupies a visible part of an on-screen window. /// This intentionally does not require focus: background split panes still render. func isVisibleSurface(_ surfaceId: UUID) -> Bool { guard let view = surfaces[surfaceId], !view.isHiddenOrHasHiddenAncestor, let window = view.window, window.occlusionState.contains(.visible) else { return false } guard view.bounds.width > 0, view.bounds.height > 0, view.visibleRect.width > 0, view.visibleRect.height > 0 else { return false } return view.bounds.intersects(view.visibleRect) } /// Whether the user is actively looking at this worktree right now. Unknown window /// state (`nil`) is treated as not-viewed so a notification is never silently /// dropped. Canvas mode is also treated as not-viewed: the normal-mode window /// observers are torn down there, so `lastWindowIsKey`/`lastWindowIsVisible` /// freeze at their pre-canvas values and a backgrounded app would keep muting. func isViewingWorktree() -> Bool { guard !isCanvasManaged else { return false } return isSelected() && lastWindowIsKey == true && lastWindowIsVisible == true } /// Whether the user is actively looking at `surfaceId` right now. func isViewedSurface(_ surfaceId: UUID) -> Bool { if isCanvasManaged { // Canvas owns focus separately from normal-mode selection and window observers. // Its logical focus callback runs before requestFocus actually installs the responder. // Panning preserves focus. visibleRect can extend outside bounds on non-clipping views. guard isFocusedSurface(surfaceId), let view = surfaces[surfaceId], view.focused, isVisibleSurface(surfaceId), let window = view.window else { return false } return window.isKeyWindow && window.firstResponder === view } return isViewingWorktree() && isFocusedSurface(surfaceId) } func updateRunningState(for tabId: TerminalTabID) { guard let tree = trees[tabId] else { return } let now = Date() var isRunningNow = false for surface in tree.leaves() { if isRunningProgressState(surface.bridge.state.progressState) { isRunningNow = true if surfaceRunningStartedAtById[surface.id] == nil { surfaceRunningStartedAtById[surface.id] = now } } else { surfaceRunningStartedAtById.removeValue(forKey: surface.id) } } tabIsRunningById[tabId] = isRunningNow tabManager.updateDirty(tabId, isDirty: isRunningNow) emitTaskStatusIfChanged() } func emitTaskStatusIfChanged() { let newStatus = taskStatus if newStatus != lastReportedTaskStatus { lastReportedTaskStatus = newStatus onTaskStatusChanged?(newStatus) } } func emitFocusChangedIfNeeded(_ surfaceId: UUID) { guard surfaceId != lastEmittedFocusSurfaceId else { return } lastEmittedFocusSurfaceId = surfaceId onFocusChanged?(surfaceId) } func emitNotificationIndicatorIfNeeded(previousHasUnseen: Bool) { if previousHasUnseen != hasUnseenNotification { onNotificationIndicatorChanged?() } } func syncFocusIfNeeded() { guard !isCanvasManaged else { return } guard lastWindowIsKey != nil, lastWindowIsVisible != nil else { return } applySurfaceActivity() } func updateTree(_ tree: SplitTree, for tabId: TerminalTabID) { trees[tabId] = tree syncFocusIfNeeded() } 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 } } func mapGhosttySplitDirection(_ direction: GhosttySplitAction.NewDirection) -> UserCustomSplitDirection { switch direction { case .left: return .left case .right: return .right case .top: return .top case .down: return .down } } func mapUserSplitDirection(_ direction: UserCustomSplitDirection) -> SplitTree.NewDirection { switch direction { case .left: return .left case .right: return .right case .top: return .top case .down: return .down } } 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) } } 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 } } @discardableResult func closeSurface( id surfaceID: UUID, confirmation: TerminalCloseConfirmationMode = .prompt(.pane), retainForUndo: Bool = true ) -> Bool { guard let view = surfaces[surfaceID] else { return false } return closeSurface(view, confirmation: confirmation, retainForUndo: retainForUndo) } /// Ghostty asked to close the surface. `processAlive` is Ghostty's /// `needsConfirmQuit()`: false for an idle shell at its prompt as well as for /// an exited child, so it only decides whether to confirm. Whether the close /// is undoable follows the child's real state, which Ghostty reports through /// `show_child_exited` before its close request. @discardableResult func handleCloseRequest( for view: GhosttySurfaceView, processAlive: Bool, retainForUndo: Bool? = nil ) -> Bool { let confirmation: TerminalCloseConfirmationMode = processAlive ? .prompt(.pane) : .skip return closeSurface( view, confirmation: confirmation, retainForUndo: retainForUndo ?? !view.childProcessHasExited) } @discardableResult private func closeSurface( _ view: GhosttySurfaceView, confirmation: TerminalCloseConfirmationMode, retainForUndo: Bool ) -> Bool { guard surfaces[view.id] != nil else { return false } guard confirmCloseIfNeeded(surfaceIDs: [view.id], mode: confirmation) else { return false } guard let tabId = tabId(containing: view.id), let tree = trees[tabId] else { view.closeSurface() forgetSurface(view.id) return true } guard let node = tree.find(id: view.id) else { view.closeSurface() forgetSurface(view.id) return true } let retain = retainForUndo && undoCloseTimeout > .zero && !view.childProcessHasExited let wasFocused = focusedSurfaceIdByTab[tabId] == view.id let nextSurface = wasFocused ? tree.focusTargetAfterClosing(node) : nil let newTree = tree.removing(node) if newTree.isEmpty { // The last pane goes with its tab, so the undo record is a tab record. let record = retain ? makeClosedTabRecord(for: tabId) : nil if record != nil { detachAndForgetSurface(view) } else { view.closeSurface() forgetSurface(view.id) } trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) unregisterTargetHandle(for: tabId) if tabId == runScriptTabId { setRunScriptTabId(nil) } // Mirror `state.closeTab(_:)`'s `onTabClosed` emit: this path // fires when the shell process exits (ghostty-driven close) // and historically skipped the callback, which meant the // Shelf's "retire the book when its last tab closes" logic // never saw this very common path. onTabClosed?() if let record { recordClosedTab(record) } return true } let context = retainedContext(for: view.id) if retain { detachAndForgetSurface(view) } else { view.closeSurface() forgetSurface(view.id) } updateTree(newTree, for: tabId) updateRunningState(for: tabId) if wasFocused { if let nextSurface { focusSurface(nextSurface, in: tabId) } else { focusedSurfaceIdByTab.removeValue(forKey: tabId) } } if retain { onCloseRecorded?( .pane( worktreeID: worktreeID, TerminalClosedPaneRecord( tabID: tabId, view: view, previousTree: tree, wasFocused: wasFocused, context: context) ) ) } return true } 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 } 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 } } 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 } }