native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130import AppKitimport CoreGraphicsimport Foundationimport GhosttyKitimport 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<GhosttySurfaceView> { 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<UUID, SplitCreationError> { 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<GhosttySurfaceView>, 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<GhosttySurfaceView>.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<GhosttySurfaceView>.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<GhosttySurfaceView>.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<GhosttySurfaceView>.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 }}