native macOS codings agent orchestrator prowl.onev.cat
Something went wrong. Try again.
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263import AppKitimport CoreGraphicsimport Foundationimport GhosttyKitimport Observationimport ProwlCLISharedimport Sharing
let terminalStateLogger = ProwlLogger("TerminalState")let activeAgentDetectionInterval: Duration = .milliseconds(300)let idleAgentDetectionInterval: Duration = .seconds(2)
enum TerminalCloseConfirmationMode { case prompt(TerminalCloseConfirmationTarget) case skip}
enum TerminalCloseConfirmationTarget { case pane case tab case tabs(count: Int)
var messageText: String { switch self { case .pane: return String(localized: "Close Terminal Pane?") case .tab: return String(localized: "Close Terminal Tab?") case .tabs(let count): return count == 1 ? String(localized: "Close Terminal Tab?") : String(localized: "Close Terminal Tabs?") } }
var confirmButtonTitle: String { switch self { case .pane: return String(localized: "Close Pane") case .tab: return String(localized: "Close Tab") case .tabs(let count): return count == 1 ? String(localized: "Close Tab") : String(localized: "Close Tabs") } }}
/// Transition metadata only. Never carry rendered screen text in diagnostics.struct AgentDetectionDiagnostic { /// What one poll's process probe saw, before any agent-dependent work. struct Probe { let tabId: TerminalTabID let childPID: pid_t? let processGroupID: pid_t? let job: ForegroundJob? let identified: IdentifiedAgentProcess? }
let tabId: TerminalTabID let childPID: pid_t? let processGroupID: pid_t? let job: ForegroundJob? let identified: IdentifiedAgentProcess? let retainedAgent: DetectedAgent? let raw: AgentRawState? let reason: AgentScreenDetectionReason? let stabilized: AgentRawState?
init( tabId: TerminalTabID, childPID: pid_t?, processGroupID: pid_t?, job: ForegroundJob?, identified: IdentifiedAgentProcess?, retainedAgent: DetectedAgent?, raw: AgentRawState?, reason: AgentScreenDetectionReason?, stabilized: AgentRawState? ) { self.tabId = tabId self.childPID = childPID self.processGroupID = processGroupID self.job = job self.identified = identified self.retainedAgent = retainedAgent self.raw = raw self.reason = reason self.stabilized = stabilized }
init( probe: Probe, retainedAgent: DetectedAgent? = nil, raw: AgentRawState? = nil, reason: AgentScreenDetectionReason? = nil, stabilized: AgentRawState? = nil ) { self.init( tabId: probe.tabId, childPID: probe.childPID, processGroupID: probe.processGroupID, job: probe.job, identified: probe.identified, retainedAgent: retainedAgent, raw: raw, reason: reason, stabilized: stabilized) }
var summary: String { let processSummary = job?.processes .map { "\($0.pid):\($0.argv0 ?? $0.name)" } .joined(separator: ",") ?? "none" return [ "tab=\(tabId.rawValue.uuidString.prefix(8))", "childPID=\(childPID.map(String.init) ?? "nil")", "ptyPGID=\(processGroupID.map(String.init) ?? "nil")", "fgPGID=\(job.map { String($0.processGroupID) } ?? "nil")", "processes=\(processSummary)", "identified=\(identified.map { "\($0.agent.rawValue)(\($0.name)):\($0.process.pid)" } ?? "nil")", "launch=\(identified.map { String($0.launchProcessID) } ?? "nil")", "retained=\(retainedAgent?.rawValue ?? "nil")", "raw=\(raw?.rawValue ?? "nil")", "reason=\(reason?.identifier ?? "nil")", "state=\(stabilized?.rawValue ?? "nil")", ].joined(separator: " ") }}
@MainActor@Observablefinal class WorktreeTerminalState { struct SurfaceActivity: Equatable { let isVisible: Bool let isFocused: Bool }
/// One memoized agent-screen scan: the complete detection result produced for /// `text` under `agent`. Cached per surface so an unchanged screen is not /// re-parsed on the next poll and retains the rule or fallback reason. struct AgentScreenScan: Equatable { let agent: DetectedAgent let text: String let detection: AgentScreenDetection }
let tabManager: TerminalTabManager let runtime: GhosttyRuntime let worktree: Worktree private let targetHandleRegistry: TerminalTargetHandleRegistry let skipsSurfaceCreationForTesting: Bool let failsSurfaceCreationForTesting: Bool @ObservationIgnored @SharedReader private var repositorySettings: RepositorySettings var trees: [TerminalTabID: SplitTree<GhosttySurfaceView>] = [:] var surfaces: [UUID: GhosttySurfaceView] = [:] var focusedSurfaceIdByTab: [TerminalTabID: UUID] = [:] struct SurfaceLaunchProfile: Equatable { let profileID: UUID /// Display name recorded at launch. Deliberately frozen: later profile /// renames or deletions never relabel a live pane. let name: String /// The runtime this profile launched. The config root only applies to /// detections of the same runtime: after the launched agent exits, a /// manually started *different* agent in the same pane uses its default /// home, and handing it the profile home would break its session /// attribution. let runtime: AgentProfileRuntime /// Relocated runtime home for account-bound profiles; the session /// resolver uses it as the config root for this surface. Nil for pure /// presets (default home layout). let dedicatedHome: URL? /// The native session layout may be nested below the provisioned home /// (Gemini: `.gemini`, Cline: `data`). Keep provisioning and attribution /// roots distinct instead of teaching the resolver launch semantics. let sessionConfigRoot: URL?
init( profileID: UUID, name: String, runtime: AgentProfileRuntime, dedicatedHome: URL?, sessionConfigRoot: URL? = nil ) { self.profileID = profileID self.name = name self.runtime = runtime self.dedicatedHome = dedicatedHome self.sessionConfigRoot = sessionConfigRoot ?? dedicatedHome }
func configRoot(forDetected agent: DetectedAgent) -> URL? { runtime.agent == agent ? sessionConfigRoot : nil } }
var surfaceAgentStates: [UUID: PaneAgentState] = [:] @ObservationIgnored var agentDetectionCoordinators: [UUID: AgentDetectionCoordinator] = [:] /// Launch identity recorded at surface creation for Prowl-launched agent /// profiles (docs-ai 053). Never rewritten: recommendation or designation /// edits must not relabel a live pane. Detected-but-not-launched agents /// have no entry here. var launchProfilesBySurface: [UUID: SurfaceLaunchProfile] = [:] /// The managed-hook registration handed to the manager at Profile launch, /// kept so an undone close can register the surviving process again. @ObservationIgnored var launchHookRegistrationsBySurface: [UUID: AgentHookLaunchRegistration] = [:] var agentDetectionSchedules: [UUID: AgentDetectionSchedule] = [:] var agentDetectionTasks: [UUID: Task<Void, Never>] = [:] var agentDetectionPresenceBySurface: [UUID: AgentDetectionPresence] = [:] var lastAgentDetectionDiagnosticsBySurface: [UUID: String] = [:] /// OSC 7501 record trees per surface (docs-ai 079). Records belong to the terminal, /// not to a process: a store outlives `forgetSurface` while its surface is retained /// for undo and is dropped when the surface is freed or closed for good. @ObservationIgnored var programStatusStoresBySurface: [UUID: ProgramStatusRecordStore] = [:] /// The sleeping half of each detection loop; `wakeAgentDetection` cancels it so a /// report, a key press, or a finished command gets an immediate probe. @ObservationIgnored var agentDetectionSleepersBySurface: [UUID: AgentDetectionSleeper] = [:] /// The loop that currently owns each surface's schedule and sleeper. @ObservationIgnored var agentDetectionLoopTokens: [UUID: UInt64] = [:] @ObservationIgnored var agentDetectionLoopCounter: UInt64 = 0 struct AgentDetectionSleeper { let token: UInt64 let task: Task<Void, any Error> } /// Wakes that arrived while a loop was mid-tick; the next sleep is skipped. @ObservationIgnored var agentDetectionWakeRequests: Set<UUID> = [] /// Surfaces whose next probe bypasses the process cache (a `COMMAND_FINISHED` arrived). @ObservationIgnored var agentDetectionFreshProbeRequests: Set<UUID> = [] /// Surfaces a key press asked for one full (non-delegated) tick; consumed only by a /// tick that actually reads the screen, so a press during a delegated tick's /// suspension still gets its full tick next. @ObservationIgnored var agentDetectionFullTickRequests: Set<UUID> = [] /// Bumped by every cleanup of a surface's detection state. A poll re-reads it after /// each suspension: a close followed by an undo restores the same surface id, and /// the old poll's continuation must not act on the restored pane. @ObservationIgnored var agentDetectionLifecycleBySurface: [UUID: UInt64] = [:] /// What a `COMMAND_FINISHED` captured, consumed by the fresh probe that follows it. @ObservationIgnored var pendingCommandFinishedBySurface: [UUID: PendingCommandFinished] = [:] /// Unmapped `app` values already reported once per surface. @ObservationIgnored var programStatusUnmappedAppsLoggedBySurface: [UUID: Set<String>] = [:] /// Test seams: the process probe, the OSC support table, and the diagnostics sink. @ObservationIgnored var agentProcessProbeForTesting: AgentProcessProbeOverride? @ObservationIgnored var programStatusSupportForTesting: ((DetectedAgent) -> ProgramStatusSupport)? @ObservationIgnored var programStatusLogForTesting: ((String) -> Void)? typealias AgentProcessProbeOverride = @MainActor (_ processGroupID: pid_t?, _ childPID: pid_t?, _ fresh: Bool) async -> ForegroundJob? struct PendingCommandFinished: Equatable { /// The store revision when the 133 D arrived; the cleanup is scoped to it. let storeRevision: UInt64 } /// Memoizes the last agent-screen scan per surface so `detectAgentState` can /// reuse it while the terminal text and detected agent are unchanged. A /// live-but-idle agent is polled every 300 ms; without this each poll re-ran /// `DetectedAgent.detectState` — line splitting, lowercasing, and heuristic /// scans over identical text. Observation-ignored: a pure cache that never /// drives the UI. @ObservationIgnored var lastAgentScreenScanBySurface: [UUID: AgentScreenScan] = [:] /// Last `ActiveAgentEntry` emitted per surface. `detectAgentState` re-emits /// whenever any `PaneAgentState` field changes, including internal /// bookkeeping (raw-state oscillation, session miss streaks, presence /// holds); comparing against the consumer-visible entry here keeps that /// churn out of the terminal event stream and the TCA action log. var lastEmittedAgentEntriesBySurface: [UUID: ActiveAgentEntry] = [:] /// When each surface last emitted, used to space out title-only emissions. /// Pure bookkeeping for `emitAgentEntry`; no view reads it. @ObservationIgnored var lastAgentEntryEmitAtBySurface: [UUID: Date] = [:] /// The most recent title-only entry held back by coalescing. A spinner that /// stops animating produces no further title change, so without a trailing /// flush the last frame would stay on screen until some unrelated state /// change happened to carry the current title along. @ObservationIgnored var pendingAgentEntryBySurface: [UUID: ActiveAgentEntry] = [:] var tabIsRunningById: [TerminalTabID: Bool] = [:] /// Per-tab aggregate of agent busy-state: `true` when at least one surface in /// the tab has a detected agent whose stabilized `displayState` is `.working` /// or `.blocked`. OR-ed into `taskStatus` alongside `tabIsRunningById` so the /// sidebar spinner and `prowl list` reflect agent activity, not just OSC 9;4 /// command progress (which Claude Code does not emit while it works). var tabAgentBusyById: [TerminalTabID: Bool] = [:] /// Per-tab aggregate of the `.blocked` slice of `tabAgentBusyById`: `true` /// when a surface in the tab holds an agent awaiting an answer (permission /// prompt, AskUserQuestion). Kept separate rather than folded into /// `taskStatus` because the two states call for opposite affordances — a /// spinner tells you to wait, a blocked agent is waiting on you — and /// `WorktreeTaskStatus` has no case to carry the difference. var tabAgentBlockedById: [TerminalTabID: Bool] = [:] var boundDirectoryTabIDs: [String: TerminalTabID] = [:] var surfaceRunningStartedAtById: [UUID: Date] = [:] var lastDefocusedAt: Date? var runScriptTabId: TerminalTabID? var pendingSetupScript: Bool var defaultFontSize: Float32? var isEnsuringInitialTab = false var lastReportedTaskStatus: WorktreeTaskStatus? var lastEmittedFocusSurfaceId: UUID? var lastWindowIsKey: Bool? var lastWindowIsVisible: Bool? /// When `true`, Canvas owns occlusion management for this state's surfaces. /// `syncFocusIfNeeded` skips `applySurfaceActivity` to avoid overriding /// Canvas-set occlusion with stale normal-mode window activity values. var isCanvasManaged = false /// Tab whose icon picker should be presented. `nil` hides the picker. var iconPickerTabId: TerminalTabID? var notifications: [WorktreeTerminalNotification] = [] var notificationsEnabled = true var commandFinishedNotificationEnabled = true var commandFinishedNotificationThreshold = 10 var lastKeyInputTimeBySurface: [UUID: ContinuousClock.Instant] = [:] var commandFinishedWaiters: [UUID: AsyncStream<(exitCode: Int?, durationMs: Int)>.Continuation] = [:] /// Surfaces that should auto-close on the next `command_finished` event with exit code 0. /// Populated by `markSurfaceForAutoClose` and consumed (one-shot) in `handleCommandFinished`. var autoCloseSurfaceIds: Set<UUID> = [] /// Surfaces running a tracked Custom Command. The stored name is surfaced as a success /// toast when the command exits with code 0. One-shot: removed on the first finish event. var pendingCustomCommands: [UUID: String] = [:] /// Ghostty's `undo-timeout`. While positive, closed panes and tabs are /// detached and handed to `onCloseRecorded` instead of freed; zero keeps the /// historical free-on-close behavior (docs-ai 069). var undoCloseTimeout: Duration = .zero /// Collects the tab records of one batch close (Close Other Tabs, ...) so /// they reach `onCloseRecorded` as a single undoable entry. @ObservationIgnored var pendingCloseGroup: [TerminalClosedTabRecord]? /// Surfaces whose `forgetSurface` is running for a retained (undoable) close. @ObservationIgnored var retainedForUndoSurfaceIDs: Set<UUID> = [] /// Per-surface set of titles known to be the shell's idle prompt /// (the title `precmd` restores between commands). Populated by /// observing the first title that arrives after each /// `command_finished` — reliably the precmd-set prompt. Subsequent /// occurrences are skipped so they can't clobber the icon set by a /// real command. var learnedIdleTitlesBySurface: [UUID: Set<String>] = [:] /// Surfaces whose next title-change should be added to /// `learnedIdleTitlesBySurface`. Armed by `command_finished`, /// consumed by the next title arrival. var awaitingIdleTitleLearningBySurface: Set<UUID> = [] var hasUnseenNotification: Bool { notifications.contains { !$0.isRead } }
func hasUnseenNotification(forSurfaceID surfaceID: UUID) -> Bool { notifications.contains { !$0.isRead && $0.surfaceId == surfaceID } }
func hasUnseenNotification(for tabId: TerminalTabID) -> Bool { let surfaceIds = trees[tabId]?.leaves().map(\.id) ?? [] return notifications.contains { !$0.isRead && surfaceIds.contains($0.surfaceId) } }
func unreadNotifications() -> [WorktreeTerminalNotification] { notifications.filter { !$0.isRead }.sorted { left, right in if left.createdAt != right.createdAt { return left.createdAt > right.createdAt } return left.id.uuidString > right.id.uuidString } }
var canCloseFocusedTab: Bool { tabManager.selectedTabId != nil }
var canCloseFocusedSurface: Bool { guard let tabId = tabManager.selectedTabId, let focusedId = focusedSurfaceIdByTab[tabId] else { return false } return surfaces[focusedId] != nil }
var isSelected: () -> Bool = { false } /// `isViewed` is true when the notification's surface is the one the user is /// actively looking at (selected worktree, focused pane, key + visible window), /// so the reducer can suppress a redundant banner/sound for it. var onNotificationReceived: ((_ surfaceId: UUID, _ title: String, _ body: String, _ isViewed: Bool) -> Void)? var onNotificationIndicatorChanged: (() -> Void)? var onTabCreated: (() -> Void)? var onTabClosed: (() -> Void)? var onFocusChanged: ((UUID) -> Void)? var onTaskStatusChanged: ((WorktreeTaskStatus) -> Void)? var onAgentEntryChanged: ((ActiveAgentEntry) -> Void)? var onAgentEntryRemoved: ((ActiveAgentEntry.ID) -> Void)? /// Emitted exactly once after agent cleanup for each torn-down surface. var onSurfaceClosed: ((UUID) -> Void)? /// A close kept its surfaces alive; the receiver owns them until it restores /// them through `restore(tab:)` / `restore(pane:)` or frees them. var onCloseRecorded: ((TerminalCloseRecord) -> Void)? /// A retained surface's process exited during the grace window. var onRetainedSurfaceExited: ((UUID) -> Void)? /// An undo put a Profile-launched surface back; its process still signals /// under this registration, so the receiver registers it again. var onManagedHookReadopted: ((UUID, AgentHookLaunchRegistration) -> Void)? /// Every surface of this worktree is being torn down outside the undoable /// close paths (layout restore, prune); retained closes are void. var onSurfacesReset: (() -> Void)? /// Ghostty `undo` / `redo` from a surface in this worktree. Return `true` /// when something was restored or re-closed. var onUndoRequested: (() -> Bool)? var onRedoRequested: (() -> Bool)? /// The exact surface is installed but its Profile command has not been sent. /// Returning false rolls the surface back before agent input can execute. var onAgentProfileSurfacePrepared: ((UUID, AgentProfileLaunchPlan) -> Bool)? var onRunScriptStatusChanged: ((Bool) -> Void)? var onCommandPaletteToggle: (() -> Void)? var onSetupScriptConsumed: (() -> Void)? var onFontSizeAdjusted: (() -> Void)? /// Emitted when a tracked Custom Command finishes with exit code 0. /// Payload carries the user-facing command name and run duration in milliseconds. var onCustomCommandSucceeded: ((String, Int) -> Void)?
init( runtime: GhosttyRuntime, worktree: Worktree, runSetupScript: Bool = false, defaultFontSize: Float32? = nil, targetHandleRegistry: TerminalTargetHandleRegistry? = nil, titleFlushClock: any Clock<Duration> = ContinuousClock(), skipsSurfaceCreationForTesting: Bool = false, failsSurfaceCreationForTesting: Bool = false ) { self.runtime = runtime self.worktree = worktree self.targetHandleRegistry = targetHandleRegistry ?? TerminalTargetHandleRegistry() self.skipsSurfaceCreationForTesting = skipsSurfaceCreationForTesting self.failsSurfaceCreationForTesting = failsSurfaceCreationForTesting self.pendingSetupScript = runSetupScript self.defaultFontSize = defaultFontSize self.tabManager = TerminalTabManager(titleFlushClock: titleFlushClock) _repositorySettings = SharedReader( wrappedValue: RepositorySettings.default, .repositorySettings(worktree.repositoryRootURL) ) self.tabManager.onCoalescedTitlesFlushed = { [weak self] tabIDs in for tabID in tabIDs { self?.refreshAgentEntriesForTitleChange(in: tabID) } } }
var worktreeID: Worktree.ID { worktree.id } var worktreeName: String { worktree.name } var repositoryRootURL: URL { worktree.repositoryRootURL }
func registerTargetHandle(for tabID: TerminalTabID) -> Int { targetHandleRegistry.register(tabID: tabID) }
func registerTargetHandle(for paneID: UUID) -> Int { targetHandleRegistry.register(paneID: paneID) }
func tabHandle(for tabID: TerminalTabID) -> Int? { targetHandleRegistry.handle(for: tabID) }
func paneHandle(for paneID: UUID) -> Int? { targetHandleRegistry.handle(for: paneID) }
func unregisterTargetHandle(for tabID: TerminalTabID) { targetHandleRegistry.unregister(tabID: tabID) }
func unregisterTargetHandle(for paneID: UUID) { targetHandleRegistry.unregister(paneID: paneID) }
var activeSurfaceView: GhosttySurfaceView? { guard let selectedTabId = tabManager.selectedTabId, let surfaceId = focusedSurfaceIdByTab[selectedTabId] else { return nil } return surfaces[surfaceId] }
var activeSurfaceID: UUID? { currentFocusedSurfaceId() }
func surfaceView(for tabId: TerminalTabID) -> GhosttySurfaceView? { guard let surfaceId = focusedSurfaceIdByTab[tabId] else { return nil } return surfaces[surfaceId] }
func surfaceView(for surfaceID: UUID) -> GhosttySurfaceView? { surfaces[surfaceID] }
@discardableResult func insertCommittedText(_ text: String, in tabId: TerminalTabID) -> Bool { guard let surface = surfaceView(for: tabId) else { return false } surface.insertCommittedTextForBroadcast(text) wakeAgentDetection(forSurfaceID: surface.id) return true }
@discardableResult func insertCommittedText(_ text: String, in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } surface.insertCommittedTextForBroadcast(text) wakeAgentDetection(forSurfaceID: surface.id) return true }
@discardableResult func applyMirroredKey(_ key: MirroredTerminalKey, in tabId: TerminalTabID) -> Bool { guard let surface = surfaceView(for: tabId) else { return false } return surface.applyMirroredKeyForBroadcast(key) }
@discardableResult func submitLine(in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } let submitted = surface.submitLine() if submitted { wakeAgentDetection(forSurfaceID: surface.id) } return submitted }
@discardableResult func sendKeyToken(_ token: String, in surfaceID: UUID) -> Bool { guard let surface = surfaceView(for: surfaceID) else { return false } let sent = surface.sendCLIKeyToken(token) if sent { wakeAgentDetection(forSurfaceID: surface.id) } return sent }
var taskStatus: WorktreeTaskStatus { let hasRunningCommand = tabIsRunningById.values.contains(true) let hasBusyAgent = tabAgentBusyById.values.contains(true) return (hasRunningCommand || hasBusyAgent) ? .running : .idle }
/// Whether any tab holds an agent awaiting an answer. Read alongside /// `taskStatus` by the sidebar so a blocked worktree shows an attention /// affordance instead of the running spinner. `taskStatus` itself is /// unchanged, so `prowl list` keeps reporting `running` for these. var hasBlockedAgent: Bool { tabAgentBlockedById.values.contains(true) }
var isRunScriptRunning: Bool { runScriptTabId != nil }
func setDefaultFontSize(_ fontSize: Float32?) { defaultFontSize = fontSize }
func focusedFontSize() -> Float32? { guard let surfaceId = currentFocusedSurfaceId() else { return nil } return inheritedSurfaceConfig(fromSurfaceId: surfaceId, context: GHOSTTY_SURFACE_CONTEXT_TAB).fontSize }
func ensureInitialTab(focusing: Bool) { guard tabManager.tabs.isEmpty else { return } guard !isEnsuringInitialTab else { return } isEnsuringInitialTab = true Task { let setupScript: String? if pendingSetupScript { setupScript = repositorySettings.setupScript } else { setupScript = nil } await MainActor.run { if tabManager.tabs.isEmpty { _ = createTab(focusing: focusing, setupScript: setupScript) } isEnsuringInitialTab = false } } }
@discardableResult func createTab( focusing: Bool = true, selecting: Bool = true, title: String? = nil, setupScript: String? = nil, initialInput: String? = nil, inheritingFromSurfaceId: UUID? = nil, workingDirectoryOverride: URL? = nil ) -> TerminalTabID? { let context = GHOSTTY_SURFACE_CONTEXT_TAB let resolvedInheritanceSurfaceId = inheritingFromSurfaceId ?? currentFocusedSurfaceId() let title = title ?? "\(worktree.name) \(nextTabIndex())" let setupInput = setupScriptInput(setupScript: setupScript) let commandInput = initialInput.flatMap { runScriptInput($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: "terminal", isTitleLocked: false, initialInput: resolvedInput, focusing: focusing, selecting: selecting, inheritingFromSurfaceId: resolvedInheritanceSurfaceId, context: context, workingDirectoryOverride: workingDirectoryOverride ) ) if shouldConsumeSetupScript, tabId != nil { onSetupScriptConsumed?() } return tabId }
func freezeAgentProfileLaunchContext( _ request: AgentProfileLaunchRequest ) -> Result<FrozenAgentProfileLaunchContext, AgentProfileLaunchError> { let anchor: UUID? let context: ghostty_surface_context_e let tracksFocusedAnchor: Bool switch request.placement { case .tab: anchor = request.inheritanceAnchor ?? currentFocusedSurfaceId() context = GHOSTTY_SURFACE_CONTEXT_TAB tracksFocusedAnchor = request.inheritanceAnchor == nil case .split(let requestedAnchor, _, _): guard let resolved = requestedAnchor ?? currentFocusedSurfaceId(), surfaces[resolved] != nil else { return .failure(.splitAnchorUnavailable) } anchor = resolved context = GHOSTTY_SURFACE_CONTEXT_SPLIT tracksFocusedAnchor = requestedAnchor == nil } let inheritedCWD = request.workingDirectoryOverride ?? inheritedSurfaceConfig(fromSurfaceId: anchor, context: context).workingDirectory ?? worktree.workingDirectory let frozenPlacement: AgentProfileLaunchRequest.Placement = switch request.placement { case .tab(let background): .tab(background: background) case .split(_, let direction, let background): .split(anchor: anchor, direction: direction, background: background) } return .success( FrozenAgentProfileLaunchContext( request: AgentProfileLaunchRequest( plan: request.plan, placement: frozenPlacement, workingDirectoryOverride: inheritedCWD, inheritanceAnchor: anchor, title: request.title ), inheritedCWD: inheritedCWD.standardizedFileURL, anchorSurfaceID: anchor, tracksFocusedAnchor: tracksFocusedAnchor, tracksInheritedCWD: request.workingDirectoryOverride == nil ) ) }
func isAgentProfileLaunchContextValid( _ context: FrozenAgentProfileLaunchContext, inheritedCWDOverride: URL? = nil ) -> Bool { if let anchor = context.anchorSurfaceID, surfaces[anchor] == nil { return false } if context.tracksFocusedAnchor, currentFocusedSurfaceId() != context.anchorSurfaceID { return false } guard context.tracksInheritedCWD else { return true } let surfaceContext: ghostty_surface_context_e = switch context.request.placement { case .tab: GHOSTTY_SURFACE_CONTEXT_TAB case .split: GHOSTTY_SURFACE_CONTEXT_SPLIT } let currentCWD = inheritedCWDOverride ?? inheritedSurfaceConfig( fromSurfaceId: context.anchorSurfaceID, context: surfaceContext ).workingDirectory ?? worktree.workingDirectory return AgentProfileLaunchPlanner.pathString(currentCWD) == AgentProfileLaunchPlanner.pathString(context.inheritedCWD) }
/// Launches an agent profile through the deterministic A2 boundary. Explicit /// split placement never falls back to a tab; callers receive both identities /// synchronously and can resolve the exact created target without using focus. @discardableResult func launchAgentProfile( _ request: AgentProfileLaunchRequest ) -> Result<LaunchedSurface, AgentProfileLaunchError> { guard provisionAgentProfileHome(for: request.plan) else { return .failure(.homeProvisioningFailed) } return launchProvisionedAgentProfile(request) }
/// Compatibility wrapper for the shipped menu/palette path. It preserves the /// original split-to-tab fallback and UUID-only result while the CLI/runner use /// the typed request boundary above. @discardableResult func launchAgentProfile(_ plan: AgentProfileLaunchPlan) -> UUID? { guard provisionAgentProfileHome(for: plan) else { return nil } if plan.placement == .split { let splitRequest = AgentProfileLaunchRequest( plan: plan, placement: .split( anchor: nil, direction: plan.splitDirection, background: false ) ) if case .success(let launched) = launchProvisionedAgentProfile(splitRequest) { return launched.surfaceID } } return try? launchProvisionedAgentProfile( AgentProfileLaunchRequest( plan: plan, placement: .tab(background: false) ) ).get().surfaceID }
func provisionAgentProfileHome(for plan: AgentProfileLaunchPlan) -> Bool { guard let home = plan.dedicatedHome else { return true } do { try AgentProfileHomeProvisioner.provision( home: home, base: ProwlPaths.agentProfileHomesDirectory ) return true } catch { terminalStateLogger.warning("Agent profile home provisioning failed: \(error)") return false } }
private func launchProvisionedAgentProfile( _ request: AgentProfileLaunchRequest ) -> Result<LaunchedSurface, AgentProfileLaunchError> { let plan = request.plan let launched: Result<LaunchedSurface, AgentProfileLaunchError> switch request.placement { case .tab(let background): launched = createAgentProfileTab(request, background: background) case .split(let requestedAnchor, let direction, let background): guard let anchor = requestedAnchor ?? currentFocusedSurfaceId() else { return .failure(.splitAnchorUnavailable) } switch createSplit( of: anchor, direction: direction, initialInput: plan.terminalInput, workingDirectoryOverride: request.workingDirectoryOverride, additionalEnvironment: plan.surfaceEnvironment, focusing: !background, defersSurfaceCreation: true ) { case .success(let surfaceID): guard let tabID = tabID(containing: surfaceID) else { return .failure(.splitCreationFailed(.insertionFailed)) } launched = .success(LaunchedSurface(tabID: tabID, surfaceID: surfaceID)) case .failure(let error): launched = .failure(.splitCreationFailed(error)) } }
guard case .success(let surface) = launched else { return launched } launchProfilesBySurface[surface.surfaceID] = SurfaceLaunchProfile( profileID: plan.profileID, name: plan.profileName, runtime: plan.runtime, dedicatedHome: plan.dedicatedHome, sessionConfigRoot: plan.sessionConfigRoot ) launchHookRegistrationsBySurface[surface.surfaceID] = plan.hookRegistration if case .split = request.placement, let icon = Self.launchTabIcon(for: plan.runtime) { applyResolvedIcon(icon, surfaceId: surface.surfaceID, tabId: surface.tabID) } guard onAgentProfileSurfacePrepared?(surface.surfaceID, plan) != false else { rollbackAgentProfileSurface(surface, placement: request.placement) return .failure(.hookRegistrationFailed) } guard let view = surfaces[surface.surfaceID], view.armSurfaceCreation() else { rollbackAgentProfileSurface(surface, placement: request.placement) return .failure(.surfaceCreationFailed) } wakeAgentDetection(for: view, tabId: surface.tabID) return launched }
private func createAgentProfileTab( _ request: AgentProfileLaunchRequest, background: Bool ) -> Result<LaunchedSurface, AgentProfileLaunchError> { let plan = request.plan guard let tabID = createTab( TabCreation( title: request.title ?? plan.profileName, icon: Self.launchTabIcon(for: plan.runtime)?.storageString ?? "terminal", isTitleLocked: false, initialInput: runScriptInput(plan.terminalInput), focusing: !background, selecting: !background, inheritingFromSurfaceId: request.inheritanceAnchor ?? currentFocusedSurfaceId(), context: GHOSTTY_SURFACE_CONTEXT_TAB, workingDirectoryOverride: request.workingDirectoryOverride, additionalEnvironment: plan.surfaceEnvironment, defersSurfaceCreation: true ) ) else { return .failure(.tabCreationFailed) } guard let surfaceID = trees[tabID]?.root?.leftmostLeaf().id else { return .failure(.launchedSurfaceMissing(tabID)) } return .success(LaunchedSurface(tabID: tabID, surfaceID: surfaceID)) }
private func rollbackAgentProfileSurface( _ surface: LaunchedSurface, placement: AgentProfileLaunchRequest.Placement ) { // The surface never ran its Profile command: nothing worth restoring. switch placement { case .tab: _ = closeTab(surface.tabID, confirmation: .skip, retainForUndo: false) case .split: _ = closeSurface(id: surface.surfaceID, confirmation: .skip, retainForUndo: false) } }
/// Icon for a profile launch. The launch path knows its runtime, so it /// resolves the brand icon directly instead of waiting for `CommandIconMap` /// to recognise the shell title: a profile that sets launch-scoped /// environment variables runs as `env VAR=… claude`, whose first token is /// `env`, and an unmatched token leaves the icon untouched. The lock stays /// `.auto`, so a later recognised command in the same tab can still take the /// slot, exactly as it does for a hand-typed agent. `nil` means the runtime /// has no mapping, in which case the tab keeps whatever icon it has. static func launchTabIcon(for runtime: AgentProfileRuntime) -> TabIconSource? { CommandIconMap.iconForFirstToken(runtime.agent.iconLookupToken) }
@discardableResult func focusOrCreateTab( boundToDirectory directory: URL, title: String? ) -> TerminalTabID? { let directoryKey = boundDirectoryKey(for: directory) if let tabId = boundDirectoryTabIDs[directoryKey], tabManager.tabs.contains(where: { $0.id == tabId }) { selectTab(tabId) return tabId } boundDirectoryTabIDs.removeValue(forKey: directoryKey)
if let tabId = tabID(withWorkingDirectoryKey: directoryKey) { boundDirectoryTabIDs[directoryKey] = tabId selectTab(tabId) return tabId }
let tabId = createTab( title: title, workingDirectoryOverride: directory ) if let tabId { boundDirectoryTabIDs[directoryKey] = tabId } return tabId }
@discardableResult func runScript(_ script: String) -> TerminalTabID? { guard let input = runScriptInput(script) else { return nil } if let existing = runScriptTabId { closeTab(existing, confirmation: .skip, retainForUndo: false) } let tabId = createTab( TabCreation( title: String(localized: "RUN SCRIPT"), icon: "play.fill", isTitleLocked: true, initialInput: input, focusing: true, inheritingFromSurfaceId: currentFocusedSurfaceId(), context: GHOSTTY_SURFACE_CONTEXT_TAB, workingDirectoryOverride: nil ) ) if let tabId { // Lock in the play glyph as a script-level override so OSC-2 // titles emitted by the script (e.g. `npm run dev`) can't swap // the icon out from under it. tabManager.setScriptIcon(tabId, icon: "play.fill") } setRunScriptTabId(tabId) return tabId }
@discardableResult func stopRunScript() -> Bool { guard let runScriptTabId else { return false } return closeTab(runScriptTabId, confirmation: .skip, retainForUndo: false) }
private struct TabCreation: Equatable { let title: String let icon: String? let isTitleLocked: Bool let initialInput: String? let focusing: Bool var selecting: Bool = true let inheritingFromSurfaceId: UUID? let context: ghostty_surface_context_e let workingDirectoryOverride: URL? var additionalEnvironment: [String: String] = [:] var defersSurfaceCreation = false }
private func createTab(_ creation: TabCreation) -> TerminalTabID? { let tabId = tabManager.createTab( title: creation.title, icon: creation.icon, isTitleLocked: creation.isTitleLocked, select: creation.selecting ) let tree = splitTree( for: tabId, inheritingFromSurfaceId: creation.inheritingFromSurfaceId, initialInput: creation.initialInput, workingDirectoryOverride: creation.workingDirectoryOverride, context: creation.context, additionalEnvironment: creation.additionalEnvironment, defersSurfaceCreation: creation.defersSurfaceCreation ) _ = registerTargetHandle(for: tabId) for surface in tree.leaves() { _ = registerTargetHandle(for: surface.id) } tabIsRunningById[tabId] = false if creation.focusing, let surface = tree.root?.leftmostLeaf() { focusSurface(surface, in: tabId) } onTabCreated?() return tabId }
func selectTab(_ tabId: TerminalTabID) { tabManager.selectTab(tabId) focusSurface(in: tabId) emitTaskStatusIfChanged() }
func focusSelectedTab() { terminalStateLogger.interval("focusSelectedTab") { guard let tabId = tabManager.selectedTabId else { return } focusSurface(in: tabId) } }
@discardableResult func focusAndInsertText(_ text: String) -> Bool { guard let tabId = tabManager.selectedTabId, let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] else { return false } surface.requestFocus() surface.insertText(text, replacementRange: NSRange(location: 0, length: 0)) return true }
@discardableResult func focusAndRunCommand(_ text: String) -> Bool { guard let tabId = tabManager.selectedTabId, let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] else { return false } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return false } let command = text.trimmingCharacters(in: .newlines) surface.requestFocus() surface.insertText(command, replacementRange: NSRange(location: 0, length: 0)) return surface.submitLine() }
func syncFocus(windowIsKey: Bool, windowIsVisible: Bool) { terminalStateLogger.interval("syncFocus") { lastWindowIsKey = windowIsKey lastWindowIsVisible = windowIsVisible applySurfaceActivity() } }
func applySurfaceActivity() { terminalStateLogger.interval("applySurfaceActivity") { applySurfaceActivityImpl() } }
private func applySurfaceActivityImpl() { 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 { return false } tabManager.selectTab(tabId) focusSurface(surface, in: tabId) return true }
@discardableResult func closeFocusedTab() -> Bool { guard let tabId = tabManager.selectedTabId else { return false } return closeTab(tabId) }
@discardableResult func closeFocusedSurface() -> Bool { guard let tabId = tabManager.selectedTabId, let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] else { return false } surface.performBindingAction("close_surface") return true }
@discardableResult func performBindingActionOnFocusedSurface(_ action: String) -> Bool { guard let tabId = tabManager.selectedTabId, let focusedId = focusedSurfaceIdByTab[tabId], let surface = surfaces[focusedId] else { return false } surface.performBindingAction(action) return true }
@discardableResult func performBindingAction(_ action: String, onSurfaceID surfaceID: UUID) -> Bool { guard let surface = surfaces[surfaceID] else { return false } surface.performBindingAction(action) return true }
@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 }
@discardableResult func closeTab(_ tabId: TerminalTabID) -> Bool { closeTab(tabId, confirmation: .prompt(.tab)) }
/// `retainForUndo: false` frees the surfaces at once, for closes that make /// no sense to restore (a replaced Run Script tab, a rolled-back launch). @discardableResult func closeTab( _ tabId: TerminalTabID, confirmation: TerminalCloseConfirmationMode, retainForUndo: Bool = true ) -> Bool { guard confirmCloseIfNeeded(tabIds: [tabId], mode: confirmation) else { return false } let wasRunScriptTab = tabId == runScriptTabId let record = retainForUndo ? makeClosedTabRecord(for: tabId) : nil if record != nil { detachTree(for: tabId) } else { removeTree(for: tabId) } unregisterTargetHandle(for: tabId) removeBoundDirectoryTab(tabId) tabManager.closeTab(tabId) if let selected = tabManager.selectedTabId { focusSurface(in: selected) } else { lastEmittedFocusSurfaceId = nil } emitTaskStatusIfChanged() if wasRunScriptTab { setRunScriptTabId(nil) } onTabClosed?() if let record { recordClosedTab(record) } return true }
func closeOtherTabs(keeping tabId: TerminalTabID) { let ids = tabManager.tabs.map(\.id).filter { $0 != tabId } guard confirmCloseIfNeeded(tabIds: ids, mode: .prompt(.tabs(count: ids.count))) else { return } closeTabs(ids) }
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) guard confirmCloseIfNeeded(tabIds: ids, mode: .prompt(.tabs(count: ids.count))) else { return } closeTabs(ids) }
func closeAllTabs() { let ids = tabManager.tabs.map(\.id) guard confirmCloseIfNeeded(tabIds: ids, mode: .prompt(.tabs(count: ids.count))) else { return } closeTabs(ids) }
/// Closes already-confirmed tabs as one undoable batch. func closeTabs(_ ids: [TerminalTabID]) { pendingCloseGroup = [] for id in ids { closeTab(id, confirmation: .skip) } let records = pendingCloseGroup ?? [] pendingCloseGroup = nil if !records.isEmpty { onCloseRecorded?(.tabs(worktreeID: worktreeID, records)) } }
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 formatCommandInput(script) }
func removeBoundDirectoryTab(_ tabId: TerminalTabID) { boundDirectoryTabIDs = boundDirectoryTabIDs.filter { $0.value != tabId } }
private func tabID(withWorkingDirectoryKey directoryKey: String) -> TerminalTabID? { for tab in tabManager.tabs { let paneIDs = trees[tab.id]?.leaves().map(\.id) ?? [] let hasMatchingPane = paneIDs.contains { paneID in guard let workingDirectory = inheritedSurfaceConfig( fromSurfaceId: paneID, context: GHOSTTY_SURFACE_CONTEXT_TAB ).workingDirectory else { return false } return boundDirectoryKey(for: workingDirectory) == directoryKey } if hasMatchingPane { return tab.id } } return nil }
private func boundDirectoryKey(for url: URL) -> String { url.standardizedFileURL.path(percentEncoded: false) }
// Env vars are injected into the surface's shell process via // `GhosttySurfaceView(environment:)`, so scripts no longer need a shell // export prefix. private func formatCommandInput(_ script: String) -> String? { makeCommandInput(script: script) }
func runScriptInput(_ script: String) -> String? { formatCommandInput(script) }
// Appends a bare `exit`, which preserves the most recent command status in // bash, zsh, and fish while remaining portable across those shells. // Without this, the interactive shell stays alive after the script finishes // and GHOSTTY_ACTION_SHOW_CHILD_EXITED never fires for completion detection. private func blockingScriptInput(_ script: String) -> String? { makeBlockingScriptInput(script: script) }
func setRunScriptTabId(_ tabId: TerminalTabID?) { let wasRunning = runScriptTabId != nil runScriptTabId = tabId let isRunning = tabId != nil if wasRunning != isRunning { onRunScriptStatusChanged?(isRunning) } }
}