diff --git a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift index a6d5b7f52..5377e2ae0 100644 --- a/SupacodeSettingsFeature/Reducer/SettingsFeature.swift +++ b/SupacodeSettingsFeature/Reducer/SettingsFeature.swift @@ -81,6 +81,7 @@ public struct SettingsFeature { public var terminateSessionsOnQuit: Bool public var remoteSessionPersistenceEnabled: Bool public var appVisibility: AppVisibility + public var terminalHibernationEnabled: Bool public var cliInstallState = CLIInstallState.checking /// Installed editors in menu order, resolved once off the picker's body. public var installedOpenActions: [OpenWorktreeAction] @@ -136,6 +137,7 @@ public struct SettingsFeature { terminateSessionsOnQuit = settings.terminateSessionsOnQuit remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled appVisibility = settings.appVisibility + terminalHibernationEnabled = settings.terminalHibernationEnabled defaultWorktreeBaseDirectoryPath = SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? "" } @@ -179,7 +181,8 @@ public struct SettingsFeature { confirmCloseSurface: confirmCloseSurface, terminateSessionsOnQuit: terminateSessionsOnQuit, remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled, - appVisibility: appVisibility + appVisibility: appVisibility, + terminalHibernationEnabled: terminalHibernationEnabled ) } } @@ -318,6 +321,7 @@ public struct SettingsFeature { state.terminateSessionsOnQuit = normalizedSettings.terminateSessionsOnQuit state.remoteSessionPersistenceEnabled = normalizedSettings.remoteSessionPersistenceEnabled state.appVisibility = normalizedSettings.appVisibility + state.terminalHibernationEnabled = normalizedSettings.terminalHibernationEnabled state.defaultWorktreeBaseDirectoryPath = normalizedSettings.defaultWorktreeBaseDirectoryPath ?? "" state.syncGlobalDefaults(from: normalizedSettings) synchronizeRepositorySelection(for: &state) diff --git a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift index 19d844007..e47937f3c 100644 --- a/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift +++ b/SupacodeSettingsFeature/Views/AppearanceSettingsView.swift @@ -132,6 +132,17 @@ public struct AppearanceSettingsView: View { Text("Allow Arbitrary Actions") Text("Skip the confirmation dialog for commands and destructive actions.") } + Toggle(isOn: $store.terminalHibernationEnabled) { + HStack(spacing: 6) { + Text("Hibernate inactive terminals") + BetaBadge() + } + Text( + "Background terminal tabs release their renderer after a few minutes of inactivity " + + "and reconnect instantly when viewed. Sessions and running agents are unaffected." + ) + } + .help("Free memory by suspending the renderer of terminal tabs you are not viewing.") } } .formStyle(.grouped) @@ -142,3 +153,17 @@ public struct AppearanceSettingsView: View { .navigationTitle("General") } } + +/// Small system-styled tag marking a setting as Beta. Uses `.quaternary` fill so +/// it tracks the theme and never introduces a custom color. +private struct BetaBadge: View { + var body: some View { + Text("Beta") + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: .capsule) + } +} diff --git a/SupacodeSettingsShared/Models/GlobalSettings.swift b/SupacodeSettingsShared/Models/GlobalSettings.swift index 87a146cb3..6a9186a9e 100644 --- a/SupacodeSettingsShared/Models/GlobalSettings.swift +++ b/SupacodeSettingsShared/Models/GlobalSettings.swift @@ -107,6 +107,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { public var remoteSessionPersistenceEnabled: Bool /// Where Supacode appears: Dock, menu bar, or both. public var appVisibility: AppVisibility + /// Beta: hidden terminal tabs release their renderer after a few minutes of + /// inactivity and reconnect when viewed. On by default. + public var terminalHibernationEnabled: Bool public static let `default` = GlobalSettings( appearanceMode: .dark, @@ -183,7 +186,8 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { confirmCloseSurface: Bool = true, terminateSessionsOnQuit: Bool = false, remoteSessionPersistenceEnabled: Bool = true, - appVisibility: AppVisibility = .dockAndMenuBar + appVisibility: AppVisibility = .dockAndMenuBar, + terminalHibernationEnabled: Bool = true ) { self.appearanceMode = appearanceMode self.defaultEditorID = defaultEditorID @@ -221,6 +225,7 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { self.terminateSessionsOnQuit = terminateSessionsOnQuit self.remoteSessionPersistenceEnabled = remoteSessionPersistenceEnabled self.appVisibility = appVisibility + self.terminalHibernationEnabled = terminalHibernationEnabled } /// Keys for reading renamed settings fields that no longer @@ -397,5 +402,9 @@ public nonisolated struct GlobalSettings: Codable, Equatable, Sendable { ((try? container.decodeIfPresent(String.self, forKey: .appVisibility)) ?? nil) .flatMap(AppVisibility.init(rawValue:)) ?? Self.default.appVisibility + // Pre-feature files omit this key; the Beta feature falls back to the default. + terminalHibernationEnabled = + try container.decodeIfPresent(Bool.self, forKey: .terminalHibernationEnabled) + ?? Self.default.terminalHibernationEnabled } } diff --git a/supacode/Clients/Terminal/TerminalClient.swift b/supacode/Clients/Terminal/TerminalClient.swift index fdf3e8a31..f12131343 100644 --- a/supacode/Clients/Terminal/TerminalClient.swift +++ b/supacode/Clients/Terminal/TerminalClient.swift @@ -73,6 +73,9 @@ struct TerminalClient { case enforceNotificationRetentionLimit case setSelectedWorktreeID(Worktree.ID?) case refreshTabBarVisibility + /// Fans a hibernation Beta-flag flip into every worktree state: enabling + /// re-arms grace timers for hidden tabs, disabling cancels pending ones. + case setTerminalHibernationEnabled(Bool) } enum Event: Equatable { diff --git a/supacode/Clients/Zmx/ZmxIPCFrameDecoder.swift b/supacode/Clients/Zmx/ZmxIPCFrameDecoder.swift new file mode 100644 index 000000000..59e0dad03 --- /dev/null +++ b/supacode/Clients/Zmx/ZmxIPCFrameDecoder.swift @@ -0,0 +1,65 @@ +import Foundation + +/// One framed zmx IPC message off the wire. `tag` is the raw wire value; the +/// consumer maps it (see `ZmxIPCTag`) and ignores anything it does not care +/// about, so an unknown tag is skipped rather than fatal. +nonisolated struct ZmxIPCFrame: Equatable, Sendable { + let tag: UInt8 + let payload: [UInt8] +} + +/// zmx IPC tag values a passive tail client observes (full enum in +/// `ThirdParty/zmx/src/ipc.zig`). A passive client only needs `.output` +/// (broadcast pty chunks); other tags are ignored. +nonisolated enum ZmxIPCTag { + static let output: UInt8 = 1 +} + +/// Incremental decoder for zmx's IPC framing: an 8-byte header (byte 0 tag, +/// bytes 1-4 little-endian `u32` len, bytes 5-7 padding) then `len` payload +/// bytes. Chunks split arbitrarily, so unconsumed bytes stay buffered until the +/// next `decode(_:)`. +nonisolated struct ZmxIPCFrameDecoder { + static let headerSize = 8 + + /// Generous headroom over the ~4 KiB pty-chunk norm; a declared length above it + /// means a desynced or corrupt stream, so decoding throws instead of buffering. + static let maxPayloadSize = 1 << 20 + + enum DecodeError: Error, Equatable { + case payloadTooLarge(UInt32) + } + + private var buffer: [UInt8] = [] + + mutating func decode(_ bytes: [UInt8]) throws -> [ZmxIPCFrame] { + buffer.append(contentsOf: bytes) + var frames: [ZmxIPCFrame] = [] + var offset = 0 + while buffer.count - offset >= Self.headerSize { + let length = Self.readLength(buffer, at: offset + 1) + guard length <= Self.maxPayloadSize else { + throw DecodeError.payloadTooLarge(length) + } + let total = Self.headerSize + Int(length) + guard buffer.count - offset >= total else { break } + let payloadStart = offset + Self.headerSize + frames.append( + ZmxIPCFrame( + tag: buffer[offset], + payload: Array(buffer[payloadStart..<(payloadStart + Int(length))]) + ) + ) + offset += total + } + if offset > 0 { buffer.removeFirst(offset) } + return frames + } + + private static func readLength(_ bytes: [UInt8], at index: Int) -> UInt32 { + UInt32(bytes[index]) + | UInt32(bytes[index + 1]) << 8 + | UInt32(bytes[index + 2]) << 16 + | UInt32(bytes[index + 3]) << 24 + } +} diff --git a/supacode/Clients/Zmx/ZmxOSCScanner.swift b/supacode/Clients/Zmx/ZmxOSCScanner.swift new file mode 100644 index 000000000..4513dbe55 --- /dev/null +++ b/supacode/Clients/Zmx/ZmxOSCScanner.swift @@ -0,0 +1,130 @@ +import Foundation +import SupacodeSettingsShared + +nonisolated private let scannerLogger = SupaLogger("ZmxOSCScanner") + +/// A complete OSC sequence lifted off a dormant session's broadcast stream. +/// `code` is the identifier the sink consumes (9 notification, 3008 agent +/// events, 0/2 title; other codes drop). `payload` is the bytes after the `;`. +nonisolated struct ZmxOSCSequence: Equatable, Sendable { + let code: Int + let payload: [UInt8] + + var payloadString: String? { String(bytes: payload, encoding: .utf8) } +} + +/// Incremental extractor for OSC sequences (`ESC ] [; data] `, +/// terminator BEL `0x07` or ST `ESC \`) in a raw terminal byte stream. Fed +/// arbitrary chunks, so parse state persists across `scan(_:)` calls; a sequence +/// may split across reads. Non-OSC bytes are discarded. +nonisolated struct ZmxOSCScanner { + /// Hard cap on one sequence's accumulated bytes. Past this the scanner reads + /// on to the terminator but drops the sequence, so a runaway never swallows + /// the next one. + static let maxSequenceLength = 8192 + + private enum State { + /// Outside any sequence, waiting for `ESC`. + case ground + /// Saw `ESC` in ground, expecting `]` to open an OSC. + case escape + /// Inside an OSC, accumulating payload bytes. + case osc + /// Saw `ESC` inside an OSC, expecting `\` to close it (ST). + case oscEscape + } + + private var state: State = .ground + private var buffer: [UInt8] = [] + private var overflowed = false + + mutating func scan(_ bytes: [UInt8]) -> [ZmxOSCSequence] { + var out: [ZmxOSCSequence] = [] + for byte in bytes { + switch state { + case .ground: + if byte == 0x1B { state = .escape } + case .escape: + if byte == 0x5D { + openSequence() + } else if byte != 0x1B { + // Not `]` and not a fresh `ESC`: abandon and return to ground. + state = .ground + } + case .osc: + if byte == 0x07 { + finish(into: &out) + } else if byte == 0x1B { + state = .oscEscape + } else { + append(byte) + } + case .oscEscape: + if byte == 0x5C { + finish(into: &out) + } else { + // Malformed ST: abandon the in-progress sequence and reprocess this + // byte from ground so a fresh `ESC` still opens the next sequence. + reset() + if byte == 0x1B { state = .escape } + } + } + } + return out + } + + private mutating func openSequence() { + state = .osc + buffer.removeAll(keepingCapacity: true) + overflowed = false + } + + private mutating func append(_ byte: UInt8) { + guard buffer.count < Self.maxSequenceLength else { + overflowed = true + return + } + buffer.append(byte) + } + + private mutating func finish(into out: inout [ZmxOSCSequence]) { + defer { reset() } + guard !overflowed else { + let code = Self.parse(buffer)?.code + scannerLogger.debug( + "Discarding overflowed OSC (code \(code.map(String.init) ?? "?"), capped at \(buffer.count) bytes)") + return + } + guard let sequence = Self.parse(buffer) else { return } + out.append(sequence) + } + + private mutating func reset() { + state = .ground + buffer.removeAll(keepingCapacity: true) + overflowed = false + } + + /// Splits `[;data]` into a numeric code and the trailing payload. + private static func parse(_ bytes: [UInt8]) -> ZmxOSCSequence? { + guard !bytes.isEmpty else { return nil } + if let separator = bytes.firstIndex(of: 0x3B) { + guard let code = parseCode(bytes[..) -> Int? { + guard !bytes.isEmpty, bytes.count <= 6 else { return nil } + var value = 0 + for byte in bytes { + guard byte >= 0x30, byte <= 0x39 else { return nil } + value = value * 10 + Int(byte - 0x30) + } + return value + } +} diff --git a/supacode/Clients/Zmx/ZmxSessionWatcher.swift b/supacode/Clients/Zmx/ZmxSessionWatcher.swift new file mode 100644 index 000000000..023010226 --- /dev/null +++ b/supacode/Clients/Zmx/ZmxSessionWatcher.swift @@ -0,0 +1,414 @@ +import ConcurrencyExtras +import Darwin +import Dependencies +import Foundation +import SupacodeSettingsShared + +nonisolated private let watcherLogger = SupaLogger("ZmxSessionWatcher") + +/// A passive tail reader over one dormant session's zmx socket. Lifecycle is +/// main-actor; the blocking socket read runs on a dedicated thread that never +/// retains the owner. Single-use: once stopped it stays stopped. +@MainActor +protocol ZmxSessionWatching: AnyObject { + func start() + func stop() +} + +/// Watches one dormant zmx session for OSC signals its surface can no longer +/// parse. A passive client that never sends `.Init` (no screen replay), it +/// feeds framed `.Output` to an OSC scanner and reconnects with bounded backoff. +@MainActor +final class ZmxSessionWatcher: ZmxSessionWatching { + /// Reconnect / backoff budget for the reader thread. Injectable so tests + /// drive give-up deterministically without real backoff sleeps. + nonisolated struct Tuning: Sendable { + /// Failed cycles (bad connect or a read cycle that made no progress) before + /// the watcher gives up; a live dormant daemon never trips this. + var maxConnectAttempts: Int + var baseBackoffMilliseconds: UInt32 + var maxBackoffMilliseconds: UInt32 + /// Poll timeout. `stop()` never blocks; this only bounds how long the reader + /// thread takes to notice the stop flag and close the socket. + var pollTimeoutMilliseconds: Int32 + /// A read cycle that delivered no frame but stayed connected at least this + /// long still counts as healthy and resets the budget. + var minHealthyConnectionMilliseconds: UInt64 + + static let live = Tuning( + maxConnectAttempts: 6, + baseBackoffMilliseconds: 200, + maxBackoffMilliseconds: 5000, + pollTimeoutMilliseconds: 200, + minHealthyConnectionMilliseconds: 1000 + ) + } + + let surfaceID: UUID + private let socketPath: String + private let tuning: Tuning + private let onSequences: @Sendable ([ZmxOSCSequence]) -> Void + /// Fired on the reader thread when it exits (give-up or stop). Test seam for + /// observing give-up deterministically. + private let onReaderFinished: (@Sendable () -> Void)? + /// Shared by reference so the reader thread never retains `self`. + private let stopped = LockIsolated(false) + private var thread: Thread? + + init( + surfaceID: UUID, + socketPath: String, + tuning: Tuning = .live, + onReaderFinished: (@Sendable () -> Void)? = nil, + onSequences: @escaping @Sendable ([ZmxOSCSequence]) -> Void + ) { + self.surfaceID = surfaceID + self.socketPath = socketPath + self.tuning = tuning + self.onReaderFinished = onReaderFinished + self.onSequences = onSequences + } + + func start() { + guard !stopped.value else { + watcherLogger.debug("Ignoring start() on a stopped zmx watcher; create a new instance to restart") + return + } + guard thread == nil else { return } + let stopped = self.stopped + let socketPath = self.socketPath + let tuning = self.tuning + let onSequences = self.onSequences + let onReaderFinished = self.onReaderFinished + let thread = Thread { + Self.run( + socketPath: socketPath, + tuning: tuning, + stopped: stopped, + onSequences: onSequences, + onReaderFinished: onReaderFinished + ) + } + thread.name = "supacode.zmx-watcher" + self.thread = thread + thread.start() + } + + func stop() { + stopped.setValue(true) + thread = nil + } + + isolated deinit { + guard !stopped.value else { return } + stop() + } + + // MARK: - Reader thread (nonisolated). + + /// Outcome of one connected read cycle, deciding whether the reconnect budget + /// resets or is charged. `.failed` covers a decoder desync (even after frames), + /// a poll / read error, or a connection that closed before delivering anything. + private enum CycleOutcome { + case progressed + case cleanEnd + case failed + } + + private nonisolated static func run( + socketPath: String, + tuning: Tuning, + stopped: LockIsolated, + onSequences: @Sendable ([ZmxOSCSequence]) -> Void, + onReaderFinished: (@Sendable () -> Void)? + ) { + defer { onReaderFinished?() } + var attempt = 0 + var lastConnectErrno: Int32 = 0 + while !stopped.value { + guard let socketFD = connect(to: socketPath, lastConnectErrno: &lastConnectErrno) else { + guard + advanceFailureBudget( + attempt: &attempt, socketPath: socketPath, tuning: tuning, stopped: stopped, + lastConnectErrno: lastConnectErrno) + else { return } + continue + } + // A successful connect invalidates any earlier connect errno; a later + // read-failure give-up must not report it as the cause. + lastConnectErrno = 0 + let outcome = readUntilClosed( + socketFD: socketFD, tuning: tuning, stopped: stopped, onSequences: onSequences) + close(socketFD) + if stopped.value { return } + switch outcome { + case .progressed, .cleanEnd: + // A healthy cycle resets the budget, but still backs off so no reconnect + // path is a tight loop. + attempt = 0 + sleepInterruptibly(tuning.baseBackoffMilliseconds, stopped: stopped) + case .failed: + guard + advanceFailureBudget( + attempt: &attempt, socketPath: socketPath, tuning: tuning, stopped: stopped, + lastConnectErrno: lastConnectErrno) + else { return } + } + } + } + + /// Advances the shared reconnect budget after a cycle that made no progress + /// and sleeps the backoff. Returns false once the budget is exhausted and the + /// watcher must give up. + private nonisolated static func advanceFailureBudget( + attempt: inout Int, + socketPath: String, + tuning: Tuning, + stopped: LockIsolated, + lastConnectErrno: Int32 + ) -> Bool { + attempt += 1 + guard attempt < tuning.maxConnectAttempts else { + let session = URL(fileURLWithPath: socketPath).lastPathComponent + // Read and poll failures log their own errno inline; only a connect + // failure has a cause worth naming here. + let cause = lastConnectErrno == 0 ? "" : " (last connect errno \(lastConnectErrno))" + watcherLogger.warning("Stopped watching \(session) after \(attempt) failed cycles\(cause)") + return false + } + sleepInterruptibly(backoffMilliseconds(attempt, tuning: tuning), stopped: stopped) + return true + } + + /// Connects a blocking AF_UNIX stream socket to the session path. Returns nil + /// on any failure (missing socket file, no listener); the caller retries. + /// Captures the failing errno so the give-up log can name the cause. + private nonisolated static func connect(to path: String, lastConnectErrno: inout Int32) -> Int32? { + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { + lastConnectErrno = errno + return nil + } + setCloseOnExec(socketFD) + var noSIGPIPE: Int32 = 1 + _ = setsockopt( + socketFD, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, socklen_t(MemoryLayout.size)) + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = path.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + watcherLogger.warning("Socket path too long: \(path)") + close(socketFD) + return nil + } + _ = withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + pathBytes.withUnsafeBufferPointer { buffer in + memcpy(sunPath, buffer.baseAddress!, buffer.count) + } + } + let addrLen = socklen_t(MemoryLayout.size + pathBytes.count) + let result = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(socketFD, sockaddrPtr, addrLen) + } + } + guard result == 0 else { + lastConnectErrno = errno + close(socketFD) + return nil + } + return socketFD + } + + /// Reads framed `.Output` until EOF, a fatal desync, or `stop()`, polling + /// every `pollTimeoutMilliseconds` so the stop flag is honored promptly. + /// Returns the cycle outcome so the caller resets or charges the reconnect budget. + private nonisolated static func readUntilClosed( + socketFD: Int32, + tuning: Tuning, + stopped: LockIsolated, + onSequences: @Sendable ([ZmxOSCSequence]) -> Void + ) -> CycleOutcome { + var decoder = ZmxIPCFrameDecoder() + var scanner = ZmxOSCScanner() + var buffer = [UInt8](repeating: 0, count: 4096) + var deliveredFrame = false + let startedAt = DispatchTime.now().uptimeNanoseconds + while !stopped.value { + var pollFD = pollfd(fd: socketFD, events: Int16(POLLIN), revents: 0) + // Clamp to at least 1ms so a degenerate hand-built Tuning can't make the + // reader block forever and ignore stop(). + let ready = poll(&pollFD, 1, max(1, tuning.pollTimeoutMilliseconds)) + if ready < 0 { + if errno == EINTR { continue } + watcherLogger.warning("zmx watcher poll error on fd \(socketFD): errno \(errno)") + return .failed + } + guard ready > 0 else { continue } + if pollFD.revents & Int16(POLLNVAL) != 0 { return .failed } + + let bytesRead = buffer.withUnsafeMutableBytes { raw in + Darwin.read(socketFD, raw.baseAddress, raw.count) + } + if bytesRead < 0 { + if errno == EINTR { continue } + watcherLogger.warning("zmx watcher read error on fd \(socketFD): errno \(errno)") + return .failed + } + // EOF: the daemon closed (shell exited). A cycle that delivered frames or + // stayed connected long enough is a healthy session ending, not a failure. + if bytesRead == 0 { + if deliveredFrame { return .progressed } + let elapsed = DispatchTime.now().uptimeNanoseconds &- startedAt + return elapsed >= tuning.minHealthyConnectionMilliseconds &* 1_000_000 ? .cleanEnd : .failed + } + + let chunk = Array(buffer[0.. UInt32 { + let shift = min(attempt - 1, 5) + let scaled = tuning.baseBackoffMilliseconds &* (UInt32(1) << UInt32(shift)) + return min(scaled, tuning.maxBackoffMilliseconds) + } + + /// Sleeps in short steps so a `stop()` mid-backoff is honored quickly. + private nonisolated static func sleepInterruptibly( + _ milliseconds: UInt32, + stopped: LockIsolated + ) { + var remaining = milliseconds + while remaining > 0, !stopped.value { + let step = min(UInt32(50), remaining) + usleep(step &* 1000) + remaining -= step + } + } +} + +/// A no-op watcher for the test-value dependency, so integration tests exercise +/// the registry's start / stop bookkeeping without a real socket-reading thread. +@MainActor +final class InertZmxSessionWatcher: ZmxSessionWatching { + func start() {} + func stop() {} +} + +/// Builds a `ZmxSessionWatching` for a dormant session. Injected so tests +/// substitute an inert watcher; the live value builds the real tail reader. +struct ZmxSessionWatcherClient: Sendable { + var makeWatcher: + @MainActor @Sendable ( + _ surfaceID: UUID, + _ socketPath: String, + _ onSequences: @escaping @Sendable ([ZmxOSCSequence]) -> Void + ) -> ZmxSessionWatching +} + +extension ZmxSessionWatcherClient: DependencyKey { + nonisolated static let liveValue = ZmxSessionWatcherClient { surfaceID, socketPath, onSequences in + ZmxSessionWatcher(surfaceID: surfaceID, socketPath: socketPath, onSequences: onSequences) + } + + nonisolated static let testValue = ZmxSessionWatcherClient { _, _, _ in + InertZmxSessionWatcher() + } +} + +extension DependencyValues { + nonisolated var zmxSessionWatcherClient: ZmxSessionWatcherClient { + get { self[ZmxSessionWatcherClient.self] } + set { self[ZmxSessionWatcherClient.self] = newValue } + } +} + +/// Owns the passive watchers 1:1 with dormant leaf surfaces; the invariant +/// `watchedSurfaceIDs == dormant leaf surface ids` is held by `reconcile(dormantSurfaceIDs:)`. +/// Stopping a watcher closes its socket, so on an explicit close reconcile must +/// run before the session kill. +@MainActor +final class ZmxSessionWatcherRegistry { + /// Delivered on the main actor when a watched dormant session emits an OSC + /// sequence. Nil until the owner installs a sink; unrouted sequences are + /// dropped. + var onOSCSequence: ((UUID, ZmxOSCSequence) -> Void)? + + private var watchers: [UUID: ZmxSessionWatching] = [:] + private let socketDirectory: String + private let client: ZmxSessionWatcherClient + + init(socketDirectory: String = ZmxSocketBudget.socketDir()) { + @Dependency(\.zmxSessionWatcherClient) var client + self.client = client + self.socketDirectory = socketDirectory + } + + /// Test seam for the `watched == dormant leaves` invariant. + var watchedSurfaceIDs: Set { Set(watchers.keys) } + + func reconcile(dormantSurfaceIDs: Set) { + for surfaceID in watchers.keys where !dormantSurfaceIDs.contains(surfaceID) { + watchers.removeValue(forKey: surfaceID)?.stop() + } + for surfaceID in dormantSurfaceIDs where watchers[surfaceID] == nil { + startWatcher(for: surfaceID) + } + } + + func stopAll() { + for watcher in watchers.values { watcher.stop() } + watchers.removeAll() + } + + isolated deinit { stopAll() } + + private func startWatcher(for surfaceID: UUID) { + let socketPath = "\(socketDirectory)/\(ZmxSessionID.make(surfaceID: surfaceID))" + let watcher = client.makeWatcher(surfaceID, socketPath) { [weak self] sequences in + Task { @MainActor [weak self] in + guard let self else { return } + for sequence in sequences { + self.deliver(surfaceID: surfaceID, sequence: sequence) + } + } + } + watchers[surfaceID] = watcher + watcher.start() + } + + private func deliver(surfaceID: UUID, sequence: ZmxOSCSequence) { + guard let onOSCSequence else { + watcherLogger.debug("OSC \(sequence.code) from dormant \(surfaceID) dropped: no sink installed") + return + } + onOSCSequence(surfaceID, sequence) + } +} diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 736dbcc7d..d3e3bb6f4 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -160,6 +160,7 @@ struct AppFeature { var lastKnownSystemNotificationsEnabled: Bool var lastKnownAgentPresenceBadgesEnabled: Bool var lastKnownAppVisibility: AppVisibility + var lastKnownTerminalHibernationEnabled: Bool var pendingDeeplinks: [Deeplink] = [] var isDeeplinkReferenceRequested = false /// Cached projection of every primitive the menu-bar `WorktreeCommands` @@ -199,6 +200,7 @@ struct AppFeature { lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled lastKnownAgentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled lastKnownAppVisibility = settings.appVisibility + lastKnownTerminalHibernationEnabled = settings.terminalHibernationEnabled // Seed from settings so `state.allScripts` doesn't start empty before the // first `settingsChanged` delegate fires. Globals aren't worktree-scoped, // so deselection (line below in `selectedWorktreeChanged(nil)`) @@ -640,6 +642,9 @@ struct AppFeature { let agentBadgesFlipped = settings.agentPresenceBadgesEnabled != state.lastKnownAgentPresenceBadgesEnabled state.lastKnownAgentPresenceBadgesEnabled = settings.agentPresenceBadgesEnabled + let hibernationFlipped = + settings.terminalHibernationEnabled != state.lastKnownTerminalHibernationEnabled + state.lastKnownTerminalHibernationEnabled = settings.terminalHibernationEnabled let visibilityChanged = settings.appVisibility != state.lastKnownAppVisibility let previousVisibility = state.lastKnownAppVisibility // Surface the main window when the Dock icon comes back, so leaving @@ -742,6 +747,12 @@ struct AppFeature { ) ) } + if hibernationFlipped { + let enabled = settings.terminalHibernationEnabled + effects.append( + .run { _ in await terminalClient.send(.setTerminalHibernationEnabled(enabled)) } + ) + } return .merge(effects) case .openActionSelectionChanged(let action): diff --git a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift index 957ac89db..c0bb7de23 100644 --- a/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift +++ b/supacode/Features/Repositories/Reducer/SidebarItemFeature.swift @@ -122,6 +122,8 @@ struct SidebarItemFeature { /// Ghostty progress busy on any surface. Combined with `hasAgentActivity` for shimmer. var isProgressBusy: Bool = false var hasUnseenNotifications: Bool = false + /// True when every tab in the worktree is hibernated. Drives the sleep marker. + var allTabsDormant: Bool = false var notifications: IdentifiedArrayOf = [] /// Per-surface outstanding unread counts. Survives cap trimming of /// `notifications`; the inspector synthesizes a row for a surface here whose @@ -193,6 +195,9 @@ struct SidebarItemFeature { if state.hasUnseenNotifications != projection.hasUnseenNotifications { state.hasUnseenNotifications = projection.hasUnseenNotifications } + if state.allTabsDormant != projection.allTabsDormant { + state.allTabsDormant = projection.allTabsDormant + } if state.notifications != projection.notifications { state.notifications = projection.notifications } if state.unseenSurfaces != projection.unseenSurfaces { state.unseenSurfaces = projection.unseenSurfaces @@ -296,6 +301,8 @@ struct WorktreeRowProjection: Equatable, Sendable { /// Terminal-tracked user scripts; the sole populator of the row's /// `runningScripts`, so the dropdown can't drift from process state (#573). var runningScripts: IdentifiedArrayOf = [] + /// True when every tab in the worktree is hibernated. Drives the sidebar sleep marker. + var allTabsDormant: Bool = false } /// A surface with outstanding unread notifications. `count` counts unread diff --git a/supacode/Features/Repositories/Views/SidebarItemView.swift b/supacode/Features/Repositories/Views/SidebarItemView.swift index 4ed59933d..3ba5ae2da 100644 --- a/supacode/Features/Repositories/Views/SidebarItemView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemView.swift @@ -510,6 +510,7 @@ private struct TrailingView: View { let agents = store.agents let scriptColors = store.runningScripts.map(\.tint) let showsNotificationIndicator = store.hasUnseenNotifications + let showsDormantIndicator = store.allTabsDormant let notifications = Array(store.notifications) let added = store.addedLines ?? 0 let removed = store.removedLines ?? 0 @@ -527,6 +528,9 @@ private struct TrailingView: View { .help(host.displayAuthority) .accessibilityLabel("Remote host \(host.displayAuthority)") } + if showsDormantIndicator { + SidebarDormantIndicator() + } if hasStats { DiffStatsContent(addedLines: added, removedLines: removed) .equatable() @@ -562,6 +566,22 @@ private struct TrailingView: View { } } +/// Sleep marker shown when every tab in the worktree is hibernated. Clears via +/// the normal row projection once any tab wakes. +private struct SidebarDormantIndicator: View, Equatable { + var body: some View { + // The zzz glyph draws thinner than its neighbors at regular weight and its + // descending tail skews the optical center; compensate to match the wifi glyph. + Image(systemName: "zzz") + .imageScale(.small) + .font(.subheadline.weight(.semibold)) + .offset(y: 0.5) + .foregroundStyle(.secondary) + .help("Hibernated to save resources. Select to reconnect.") + .accessibilityLabel("Hibernated") + } +} + private struct PullRequestBadgeContent: View, Equatable { let text: String diff --git a/supacode/Features/Repositories/Views/SidebarItemsView.swift b/supacode/Features/Repositories/Views/SidebarItemsView.swift index 1d7d70c56..29a948143 100644 --- a/supacode/Features/Repositories/Views/SidebarItemsView.swift +++ b/supacode/Features/Repositories/Views/SidebarItemsView.swift @@ -496,6 +496,9 @@ private struct SidebarItemBody: View { "No terminal state for worktree \(rowID) when focusing notification \(notification.surfaceID).") return } + // Without selecting the row first the jump lands in an off-screen worktree, + // marking the notification read on a pane the user never sees. + parentStore.send(.selectWorktree(rowID, focusTerminal: true)) if !terminalState.focusSurface(id: notification.surfaceID) { notificationLogger.warning("Failed to focus surface \(notification.surfaceID) for worktree \(rowID).") } diff --git a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 9b1a95793..8341de57a 100644 --- a/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/supacode/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -52,6 +52,9 @@ final class WorktreeTerminalManager { private var pendingIdleHookEvents: [IdleDebounceKey: Task] = [:] @ObservationIgnored private let hookEventSleep: @Sendable (Duration) async throws -> Void + /// Injected clock, handed to each `WorktreeTerminalState` so its hibernation + /// grace timers run on the same time source as the manager. + @ObservationIgnored private let clock: any Clock @ObservationIgnored @Dependency(\.zmxClient) private var zmxClient @ObservationIgnored @Dependency(\.analyticsClient) private var analyticsClient /// Serialized off-main writer that merges per-worktree layout changes into @@ -157,6 +160,7 @@ final class WorktreeTerminalManager { self.focusedSurfaceBackground = runtime.backgroundColor() self.hookEventSleep = { duration in try await clock.sleep(for: duration) } self.layoutDebounceSleep = { duration in try await clock.sleep(for: duration) } + self.clock = clock @Dependency(\.settingsFileStorage) var settingsFileStorage self.layoutsWriter = LayoutsIncrementalWriter(storage: settingsFileStorage) // A theme reload changes the fallback and every non-OSC surface background. @@ -335,6 +339,9 @@ final class WorktreeTerminalManager { stateIfExists(for: worktree.id)?.selectTabAtIndex(index) case .focusSurface(let worktree, let tabID, let surfaceID, let input): let terminal = state(for: worktree) + // Wake explicitly for parity with the split and destroy handlers; selectTab + // would wake a dormant tab anyway. + terminal.wakeTab(tabID) terminal.selectTab(tabID) guard terminal.focusSurface(id: surfaceID) else { terminalLogger.warning("focusSurface: surface \(surfaceID) not found in worktree \(worktree.id).") @@ -345,6 +352,9 @@ final class WorktreeTerminalManager { } case .splitSurface(let worktree, let tabID, let surfaceID, let direction, let input, let id): let terminal = state(for: worktree) + // Wake explicitly for parity with the focus and destroy handlers; selectTab + // would wake a dormant tab anyway. + terminal.wakeTab(tabID) terminal.selectTab(tabID) let ghosttyDirection: GhosttySplitAction.NewDirection = direction == .vertical ? .down : .right let resolvedInput = BlockingScriptRunner.makeCommandInput(script: input ?? "") @@ -375,6 +385,8 @@ final class WorktreeTerminalManager { terminal.closeTab(tabID) case .destroySurface(let worktree, let tabID, let surfaceID): let terminal = state(for: worktree) + // Wake explicitly for parity with the focus and split handlers. + terminal.wakeTab(tabID) terminal.selectTab(tabID) if !terminal.closeSurface(id: surfaceID) { terminalLogger.warning("destroySurface: surface \(surfaceID) not found in worktree \(worktree.id).") @@ -405,7 +417,8 @@ final class WorktreeTerminalManager { .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .performBindingAction, .performBindingActionOnSurface, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .renameTab, .setImagePasteAgents, .prune, .setNotificationsEnabled, - .enforceNotificationRetentionLimit, .setSelectedWorktreeID, .refreshTabBarVisibility, .beginTabRename: + .enforceNotificationRetentionLimit, .setSelectedWorktreeID, .refreshTabBarVisibility, .beginTabRename, + .setTerminalHibernationEnabled: return false } return true @@ -423,7 +436,8 @@ final class WorktreeTerminalManager { .runBlockingScript, .closeFocusedTab, .closeFocusedSurface, .startSearch, .searchSelection, .navigateSearchNext, .navigateSearchPrevious, .endSearch, .selectTab, .selectTabAtIndex, .focusSurface, .splitSurface, .destroyTab, .destroySurface, .renameTab, .prune, .setNotificationsEnabled, - .enforceNotificationRetentionLimit, .setSelectedWorktreeID, .refreshTabBarVisibility, .beginTabRename: + .enforceNotificationRetentionLimit, .setSelectedWorktreeID, .refreshTabBarVisibility, .beginTabRename, + .setTerminalHibernationEnabled: return false } return true @@ -447,16 +461,27 @@ final class WorktreeTerminalManager { for state in states.values { state.refreshTabBarVisibility() } + case .setTerminalHibernationEnabled(let enabled): + for state in states.values { + state.applyHibernationEnabled(enabled) + } case .setSelectedWorktreeID(let id): guard id != selectedWorktreeID else { return } if let previousID = selectedWorktreeID, let previousState = states[previousID] { previousState.rememberFocusedZoom() previousState.setAllSurfacesOccluded() previousState.forgetLastEmittedFocus() + // Deselecting schedules grace timers for every tab of the old worktree. + previousState.setWorktreeSelected(false) lastEmittedCoalescable.removeValue(forKey: .focus(previousID)) markLayoutDirty(worktreeID: previousID) } selectedWorktreeID = id + // Selecting cancels the grace timer of the worktree's selected tab; its + // other tabs stay scheduled. + if let id, let newState = states[id] { + newState.setWorktreeSelected(true) + } // A sidebar click never hands AppKit focus to the terminal, so no focus // event fires; refresh here or the window keeps the previous tint. refreshFocusedSurfaceBackground() @@ -518,6 +543,33 @@ final class WorktreeTerminalManager { return stream } + /// Wires the presence / hibernation callbacks and seeds the visibility flag. + private func configurePresenceCallbacks(for state: WorktreeTerminalState, worktree: Worktree) { + // Seed the visibility flag so a background worktree's tabs start their grace + // timers, and freeze live agent records into the layout at hibernation time. + state.setWorktreeSelected(selectedWorktreeID == worktree.id) + state.hibernationAgentsBySurface = { [weak self] in self?.currentAgentsBySurface?() ?? [:] } + state.isSelected = { [weak self] in + self?.selectedWorktreeID == worktree.id + } + state.onSurfacesClosed = { [weak self] ids in + self?.emit(.surfacesClosed(worktreeID: worktree.id, ids)) + // The last surface closing leaves no focus target, so no focus event + // follows; fall back to the theme background here. + self?.refreshFocusedSurfaceBackground() + } + // Hibernation keeps the zmx sessions and presence records; only the pending + // idle-debounce tasks for the torn-down surfaces need cancelling. + state.onSurfacesHibernated = { [weak self] ids in self?.cancelPendingIdleHooks(forSurfaceIDs: ids) } + // A hibernate / wake leaves the surface set unchanged, so re-emit the row + // projection here or the sidebar sleep marker never tracks `allTabsDormant`. + state.onDormancyChanged = { [weak self] in self?.emitProjection(for: worktree.id) } + // OSC-sourced presence events go through the existing idle-debounce funnel. + state.onAgentHookEvent = { [weak self] event in + self?.dispatchHookEvent(event) + } + } + func state( for worktree: Worktree, runSetupScriptIfNew: () -> Bool = { false } @@ -542,6 +594,7 @@ final class WorktreeTerminalManager { runtime: runtime, worktree: worktree, runSetupScript: runSetupScript, + hibernationClock: clock, surfaceBindingActionPerformer: surfaceBindingActionPerformer ) state.socketPath = socketServer?.socketPath @@ -550,19 +603,7 @@ final class WorktreeTerminalManager { state.pendingLayoutSnapshot = loadLayoutSnapshot?(worktree.id) } state.setNotificationsEnabled(notificationsEnabled) - state.isSelected = { [weak self] in - self?.selectedWorktreeID == worktree.id - } - state.onSurfacesClosed = { [weak self] ids in - self?.emit(.surfacesClosed(worktreeID: worktree.id, ids)) - // The last surface closing leaves no focus target, so no focus event - // follows; fall back to the theme background here. - self?.refreshFocusedSurfaceBackground() - } - // OSC-sourced presence events go through the existing idle-debounce funnel. - state.onAgentHookEvent = { [weak self] event in - self?.dispatchHookEvent(event) - } + configurePresenceCallbacks(for: state, worktree: worktree) state.onNotificationReceived = { [weak self] surfaceID, title, body, isViewed in self?.emit( .notificationReceived( @@ -641,6 +682,12 @@ final class WorktreeTerminalManager { customTitle: String? = nil ) { let state = state(for: worktree) { runSetupScriptIfNew } + // A CLI `tab new` on a cold-staged worktree must consume the persisted layout + // first, or `ensureInitialTab` later hits its `tabs.isEmpty` guard and strands + // the staged snapshot (then the next flush overwrites it). + if state.pendingLayoutSnapshot != nil, !state.hasAttemptedInitialTab { + state.ensureInitialTab(focusing: false) + } let setupScript: String? if state.needsSetupScript() { @SharedReader(.repositorySettings(worktree.repositoryRootURL, host: worktree.host)) @@ -746,7 +793,9 @@ final class WorktreeTerminalManager { private func flushLayoutSnapshot(worktreeID: Worktree.ID) { layoutDirtyTasks[worktreeID] = nil guard let state = states[worktreeID] else { return } - let agents = currentAgentsBySurface?() ?? [:] + // A nil map (closure unwired) keeps frozen dormant records instead of wiping + // them; production always wires the authoritative live presence source. + let agents = currentAgentsBySurface?() // A nil snapshot (no remaining tabs) clears the key rather than persisting // an empty layout, matching the on-disk "no trace" semantics for emptiness. let snapshot = state.captureLayoutSnapshot(agentsBySurface: agents) @@ -1128,7 +1177,7 @@ final class WorktreeTerminalManager { /// Embed `agentsBySurface` in each surface so badges survive relaunch. func saveAllLayoutSnapshots( - agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] = [:] + agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]]? = nil ) { guard let saveLayoutSnapshot else { assertionFailure("saveLayoutSnapshot closure not configured.") diff --git a/supacode/Features/Terminal/Models/TerminalTabManager.swift b/supacode/Features/Terminal/Models/TerminalTabManager.swift index fc9011db9..79d1c0f80 100644 --- a/supacode/Features/Terminal/Models/TerminalTabManager.swift +++ b/supacode/Features/Terminal/Models/TerminalTabManager.swift @@ -12,9 +12,20 @@ final class TerminalTabManager { editingTabID = nil } } - var selectedTabId: TerminalTabID? + var selectedTabId: TerminalTabID? { + // Single choke point for the ~nine selection write sites: the owning state + // recomputes tab visibility (and its hibernation timers) once per change. + didSet { + guard oldValue != selectedTabId else { return } + onSelectedTabChanged?() + } + } private(set) var editingTabID: TerminalTabID? + /// Fires whenever `selectedTabId` changes. Set by `WorktreeTerminalState` so a + /// selection change drives `refreshTabVisibility()` from one place. + @ObservationIgnored var onSelectedTabChanged: (() -> Void)? + private static let logger = SupaLogger("TabManager") func createTab( diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift index 92262939f..cbe5686fa 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState.swift @@ -24,6 +24,8 @@ struct WorktreeTabProjection: Equatable, Sendable { let isSplitZoomed: Bool /// Per-tab repaint epoch, bumped on same-UUID surface replacement so the view rebuilds. let surfaceGeneration: Int + /// True while the tab's surfaces are hibernated (torn down, zmx sessions kept). + let isDormant: Bool init( tabID: TerminalTabID, @@ -32,6 +34,7 @@ struct WorktreeTabProjection: Equatable, Sendable { unseenNotificationCount: Int, isSplitZoomed: Bool = false, surfaceGeneration: Int = 0, + isDormant: Bool = false, ) { self.tabID = tabID self.surfaceIDs = surfaceIDs @@ -39,6 +42,7 @@ struct WorktreeTabProjection: Equatable, Sendable { self.unseenNotificationCount = unseenNotificationCount self.isSplitZoomed = isSplitZoomed self.surfaceGeneration = surfaceGeneration + self.isDormant = isDormant } } @@ -49,14 +53,44 @@ final class WorktreeTerminalState { let isVisible: Bool let isFocused: Bool } - enum PendingCloseConfirmation: Equatable { - case surface(UUID) - case tabs([TerminalTabID]) + /// Why a close needs confirming. Nil where the close needs none. + enum CloseConfirmationReason: Equatable { + case runningProcess + /// A hibernated tab has no live surface to ask, so nothing was checked. + case dormant + + var message: String { + switch self { + case .runningProcess: "One or more terminal processes are still running. Closing will terminate them." + case .dormant: "This terminal is asleep. Closing will end its background session." + } + } + } + + struct PendingCloseConfirmation: Equatable { + enum Target: Equatable { + case surface(UUID) + case tabs([TerminalTabID]) + } + + let target: Target + /// Carried from the raise site: re-deriving it at render time would flip the + /// copy if a process reaches its prompt while the alert is up. + let reason: CloseConfirmationReason // Copy is identical for surface and tab closes, so it lives on the type. static let title = "Close Terminal?" static let actionTitle = "Close Terminal" - static let message = "One or more terminal processes are still running. Closing will terminate them." + + var message: String { reason.message } + + static func surface(_ surfaceID: UUID, reason: CloseConfirmationReason = .runningProcess) -> Self { + Self(target: .surface(surfaceID), reason: reason) + } + + static func tabs(_ tabIDs: [TerminalTabID], reason: CloseConfirmationReason = .runningProcess) -> Self { + Self(target: .tabs(tabIDs), reason: reason) + } } private struct SurfaceLaunchMetadata { @@ -64,6 +98,16 @@ final class WorktreeTerminalState { let context: ghostty_surface_context_e } + /// Frozen state of a hibernated tab: layout snapshot (pwd + agent records + /// freeze at teardown), focused leaf, and zoom. In-memory only; the dormant + /// surface UUIDs still reach layouts.json via `captureLayoutSnapshot`. + struct DormantTabLayout { + let layout: TerminalLayoutSnapshot.LayoutNode + /// Indexes `layout.leafSurfaceIDs`; consumers must bounds-check. + let focusedLeafIndex: Int? + let zoomedSurfaceID: UUID? + } + let tabManager: TerminalTabManager private let runtime: GhosttyRuntime @ObservationIgnored private let splitPreserveZoomOnNavigation: () -> Bool @@ -76,6 +120,41 @@ final class WorktreeTerminalState { // from user-initiated structural changes; per-surface churn must stay on // `surfaceStates` / `WorktreeTabProjection` to keep agent storms cold. private var trees: [TerminalTabID: SplitTree] = [:] + /// Hibernated tabs keyed by tab id. Invariant: keys ⊆ `tabManager` tab ids. + /// `@ObservationIgnored` since the tab bar reads dormancy via + /// `WorktreeTabProjection`, not this dict. The `didSet` keeps the session + /// watchers in lock-step with the dormant leaf set. + @ObservationIgnored private(set) var dormantTabLayouts: [TerminalTabID: DormantTabLayout] = [:] { + didSet { syncDormantSessionWatchers() } + } + /// Passive tail readers over dormant sessions' zmx sockets, one per dormant + /// leaf. While a tab is dark no surface parses its pty stream, so these recover + /// OSC-borne signals (notifications, presence, titles). + @ObservationIgnored private let dormantSessionWatchers = ZmxSessionWatcherRegistry() + /// True while this state's worktree is the selected one. Only the selected tab + /// of the selected worktree renders, so a tab is "hidden" (hibernation + /// candidate) unless it is that one tab. Fed by `setWorktreeSelected`. + @ObservationIgnored private var isWorktreeSelected = false + /// Beta opt-in gate, cached so the hot schedule / fire paths don't re-read the + /// shared file. Seeded at init, kept in sync by `applyHibernationEnabled`. + @ObservationIgnored private var isHibernationEnabled = false + /// Per-tab grace timers. A hidden tab hibernates once its timer fires; the + /// timer is a plain `Task` owned here so teardown drains the dict. + @ObservationIgnored private var hibernationTimers: [TerminalTabID: Task] = [:] + /// Tabs whose ineligible-deferral was already logged, so a permanently + /// ineligible hidden tab re-firing every grace window doesn't spam the log. + /// Cleared when the tab becomes eligible, visible, hibernates, or closes. + @ObservationIgnored private var loggedIneligibleDeferralTabs: Set = [] + /// Drives the grace timers. Injected from the manager's clock so a TestClock + /// advances everything in tests. + @ObservationIgnored private let hibernationClock: any Clock + /// Frozen agent records per surface, read at teardown to freeze presence badges + /// into the dormant layout. Wired by the manager to its `currentAgentsBySurface` + /// source. + @ObservationIgnored var hibernationAgentsBySurface: (() -> [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]])? + /// Logged once per state instance if the agent-records closure is unwired. + /// Production always wires it, so a nil means broken wiring, not "no agents". + @ObservationIgnored private var hasLoggedMissingAgentsClosure = false @ObservationIgnored private var surfaces: [UUID: GhosttySurfaceView] = [:] // `usesZmx` + `context` retained per surface so an unexpected zmx exit can recreate it on reattach. @ObservationIgnored private var surfaceLaunchMetadata: [UUID: SurfaceLaunchMetadata] = [:] @@ -182,10 +261,11 @@ final class WorktreeTerminalState { unseenNotificationCount(forTabID: tabID) > 0 } - /// Sum of the tab's surfaces' outstanding unread counters. + /// Sum of the tab's surfaces' outstanding unread counters. Dormant-aware: + /// `surfaceIDs(inTab:)` unions a hibernated tab's frozen leaves, whose + /// `surfaceStates` counters survive hibernation, so a dark tab keeps its count. func unseenNotificationCount(forTabID tabID: TerminalTabID) -> Int { - guard let tree = trees[tabID] else { return 0 } - return tree.leaves().reduce(0) { $0 + (surfaceStates[$1.id]?.unseenNotificationCount ?? 0) } + unseenNotificationCount(inSurfaces: surfaceIDs(inTab: tabID)) } /// Returns the most recent unread notification in this worktree, or nil. @@ -219,6 +299,13 @@ final class WorktreeTerminalState { var onSetupScriptConsumed: (() -> Void)? /// Forwarded to the manager so it can emit a `surfacesClosed` event into TCA. var onSurfacesClosed: ((Set) -> Void)? + /// Fires when a tab hibernates. Manager cancels the debounced idle hooks for + /// those surfaces WITHOUT the presence drop `onSurfacesClosed` would trigger. + var onSurfacesHibernated: ((Set) -> Void)? + /// Fires when the worktree's dormant composition changes (a tab hibernates or + /// wakes). Manager re-emits the row projection so the sidebar sleep marker + /// tracks `allTabsDormant`; nothing else re-emits on these transitions. + var onDormancyChanged: (() -> Void)? /// Forwarded to the manager's `dispatchHookEvent` so an OSC-sourced presence /// event joins the same funnel as the socket path (idle-debounce, badge). var onAgentHookEvent: ((AgentHookEvent) -> Void)? @@ -239,6 +326,7 @@ final class WorktreeTerminalState { worktree: Worktree, runSetupScript: Bool = false, splitPreserveZoomOnNavigation: (() -> Bool)? = nil, + hibernationClock: (any Clock)? = nil, surfaceNeedsCloseConfirmation: ((GhosttySurfaceView) -> Bool)? = nil, surfaceBindingActionPerformer: ((GhosttySurfaceView, String) -> Void)? = nil ) { @@ -248,16 +336,24 @@ final class WorktreeTerminalState { self.surfaceBindingActionPerformer = surfaceBindingActionPerformer ?? { $0.performBindingAction($1) } self.worktree = worktree self.pendingSetupScript = runSetupScript + self.hibernationClock = hibernationClock ?? ContinuousClock() self.tabManager = TerminalTabManager() _repositorySettings = SharedReader( wrappedValue: RepositorySettings.default, .repositorySettings(worktree.repositoryRootURL, host: worktree.host) ) + // Route every selection write through the single visibility choke point. + tabManager.onSelectedTabChanged = { [weak self] in self?.refreshTabVisibility() } + // Route dormant-session OSC signals into the notification / presence handlers. + dormantSessionWatchers.onOSCSequence = { [weak self] surfaceID, sequence in + self?.handleDormantOSCSequence(surfaceID: surfaceID, sequence: sequence) + } // Pre-hide the tab bar before the first tab is created to // avoid a visible flash. updateShouldHideTabBar() handles // the steady state once tabs exist. @Shared(.settingsFile) var settingsFile self.shouldHideTabBar = settingsFile.global.hideSingleTabBar + self.isHibernationEnabled = settingsFile.global.terminalHibernationEnabled } var taskStatus: WorktreeTaskStatus { @@ -280,6 +376,7 @@ final class WorktreeTerminalState { notifications: IdentifiedArray(uniqueElements: notifications), unseenSurfaces: unseenSurfacesProjection(), runningScripts: runningScriptsProjection(), + allTabsDormant: allTabsDormant, ) } @@ -630,15 +727,28 @@ final class WorktreeTerminalState { tabManager.tabs.contains(where: { $0.id == tabId }) } - /// Surface IDs in a single tab (one entry per leaf of the tab's split tree). - /// Empty if the tab does not exist. + /// Surface IDs in a single tab, resolving through the live tree or, when the + /// tab is hibernated, its frozen dormant leaves so validation / focus / split + /// paths still address a dark pane. Empty if the tab does not exist. func surfaceIDs(inTab tabId: TerminalTabID) -> [UUID] { - trees[tabId]?.leaves().map(\.id) ?? [] + if let tree = trees[tabId] { + return tree.leaves().map(\.id) + } + if let dormant = dormantTabLayouts[tabId] { + return dormant.layout.leafSurfaceIDs + } + return [] } - /// All surface IDs across every tab in this worktree state. + /// All surface IDs across every tab in this worktree state, including the + /// frozen leaves of hibernated tabs so teardown / reaper / prune stay total. var allSurfaceIDs: [UUID] { - trees.values.flatMap { $0.leaves().map(\.id) } + trees.values.flatMap { $0.leaves().map(\.id) } + dormantLeafSurfaceIDs + } + + /// Frozen leaves across every hibernated tab in this worktree state. + private var dormantLeafSurfaceIDs: [UUID] { + dormantTabLayouts.values.flatMap { $0.layout.leafSurfaceIDs } } /// Host of a remote worktree, nil for local. Every surface in this state @@ -659,16 +769,29 @@ final class WorktreeTerminalState { } /// O(1) emptiness check that skips the split-tree walk in `allSurfaceIDs`. - var hasAnySurface: Bool { !surfaces.isEmpty } + /// Counts hibernated tabs so a fully-dormant app still shows the + /// quit-and-terminate confirmation and tears their sessions down. + var hasAnySurface: Bool { !surfaces.isEmpty || !dormantTabLayouts.isEmpty } + /// True when the worktree has at least one tab and every tab is hibernated. + /// Drives the sidebar row's sleep marker; a single live tab keeps it false. + var allTabsDormant: Bool { + guard !tabManager.tabs.isEmpty else { return false } + return tabManager.tabs.allSatisfy { dormantTabLayouts[$0.id] != nil } + } + + /// Whether a surface lives in this tab, live or frozen in its dormant leaves, + /// so validation accepts a dormant pane before the wake-first command runs. func hasSurface(_ surfaceID: UUID, in tabId: TerminalTabID) -> Bool { - guard let tree = trees[tabId] else { return false } - return tree.find(id: surfaceID) != nil + if trees[tabId]?.find(id: surfaceID) != nil { return true } + return dormantTabLayouts[tabId]?.layout.leafSurfaceIDs.contains(surfaceID) == true } - /// Checks whether a surface UUID exists anywhere in the worktree (across all tabs). + /// Checks whether a surface UUID exists anywhere in the worktree (across all + /// tabs), including the frozen leaves of hibernated tabs so validation accepts + /// a dormant pane and duplicate-id checks catch it. func hasSurfaceAnywhere(_ surfaceID: UUID) -> Bool { - surfaces[surfaceID] != nil + isKnownSurface(surfaceID) } func selectTab(_ tabId: TerminalTabID) { @@ -755,13 +878,19 @@ final class WorktreeTerminalState { @discardableResult func focusSurface(id: UUID) -> Bool { - guard let tabId = tabID(containing: id), - let surface = surfaces[id] - else { + guard let tabId = tabID(containing: id) else { terminalStateLogger.warning("focusSurface: surface \(id) not found in worktree \(worktree.id).") return false } + // Wake first: a dormant leaf has no entry in `surfaces` to focus. + wakeTab(tabId) tabManager.selectTab(tabId) + guard let surface = surfaces[id] else { + // A partial wake reaped this leaf, so land on whatever the tab rebuilt. + terminalStateLogger.error("focusSurface: surface \(id) missing after waking tab \(tabId.rawValue).") + focusSurface(in: tabId) + return false + } focusSurface(surface, in: tabId) return true } @@ -878,7 +1007,7 @@ final class WorktreeTerminalState { // payload, never a published value a concurrent dismissal may have cleared. func confirmPendingClose(_ pending: PendingCloseConfirmation) { pendingCloseConfirmation = nil - switch pending { + switch pending.target { case .surface(let surfaceID): guard let surface = surfaces[surfaceID] else { terminalStateLogger.debug("confirmPendingClose: surface \(surfaceID) already gone.") @@ -900,7 +1029,7 @@ final class WorktreeTerminalState { // Takes the target explicitly so a dismissal that clears the published value // first can't strip the surface's explicit-close flag out from under cancel. func cancelPendingClose(_ pending: PendingCloseConfirmation) { - if case .surface(let surfaceID) = pending { + if case .surface(let surfaceID) = pending.target { pendingExplicitSurfaceCloseIDs.remove(surfaceID) } pendingCloseConfirmation = nil @@ -920,31 +1049,40 @@ final class WorktreeTerminalState { guard pendingCloseConfirmation == nil else { return true } @Shared(.settingsFile) var settingsFile - let needsConfirmation = - settingsFile.global.confirmCloseSurface - && existingTabIDs.contains(where: tabNeedsCloseConfirmation) - if needsConfirmation { - pendingCloseConfirmation = .tabs(existingTabIDs) - } else { + let reasons = existingTabIDs.compactMap(closeConfirmationReason) + guard settingsFile.global.confirmCloseSurface, !reasons.isEmpty else { for tabId in existingTabIDs { closeTab(tabId) } + return true } + // A live process outranks dormancy, so the copy names what was actually found. + pendingCloseConfirmation = .tabs( + existingTabIDs, reason: reasons.contains(.runningProcess) ? .runningProcess : .dormant) return true } - private func tabNeedsCloseConfirmation(_ tabId: TerminalTabID) -> Bool { - guard let tree = trees[tabId] else { return false } - return tree.leaves().contains(where: surfaceNeedsCloseConfirmation) + /// Nil when the tab closes without asking. + private func closeConfirmationReason(_ tabId: TerminalTabID) -> CloseConfirmationReason? { + // A woken surface reports "not at a prompt" until the zmx replay lands, so a + // dormant tab always confirms. + guard dormantTabLayouts[tabId] == nil else { return .dormant } + guard let tree = trees[tabId], tree.leaves().contains(where: surfaceNeedsCloseConfirmation) else { + return nil + } + return .runningProcess } private func removeFromPendingClose(tabId: TerminalTabID) { - guard case .tabs(let tabIDs)? = pendingCloseConfirmation else { return } + guard case .tabs(let tabIDs) = pendingCloseConfirmation?.target, + let reason = pendingCloseConfirmation?.reason + else { return } let remaining = tabIDs.filter { $0 != tabId } - pendingCloseConfirmation = remaining.isEmpty ? nil : .tabs(remaining) + pendingCloseConfirmation = remaining.isEmpty ? nil : .tabs(remaining, reason: reason) } func closeTab(_ tabId: TerminalTabID) { + cancelHibernationTimer(for: tabId) removeFromPendingClose(tabId: tabId) let closedBlockingKind = blockingScripts.removeValue(forKey: tabId) cleanupBlockingScriptLaunchDirectory(for: tabId) @@ -953,6 +1091,7 @@ final class WorktreeTerminalState { lastBlockingScriptTabByKind.removeValue(forKey: kind) } removeTree(for: tabId) + removeDormantTab(tabId) tabManager.closeTab(tabId) updateShouldHideTabBar() if let selected = tabManager.selectedTabId { @@ -994,6 +1133,31 @@ final class WorktreeTerminalState { // resurrect it: the replacement surface would be invisible, unclosable, and // hold its local and host zmx sessions alive. guard hasTab(tabId) else { return SplitTree() } + // Wake a hibernated tab before minting a fresh surface: rebuild from the + // frozen layout with the ORIGINAL UUIDs so `zmx attach` reattaches. + if let dormant = dormantTabLayouts.removeValue(forKey: tabId) { + // `removeValue` fires the didSet, stopping the woken leaves' watchers; a + // live surface now parses their streams. + let expectedLeafIDs = dormant.layout.leafSurfaceIDs + let tree = wakeDormantTab(tabId, dormant: dormant) + // A partial rebuild (an `inserting` throw in `createRestorationSplit`) can + // strand frozen leaves that end up neither live nor dormant; kill their + // orphaned zmx sessions so they don't linger until the next-launch reap. + let orphanedLeafIDs = Self.orphanedWakeLeafIDs( + expected: expectedLeafIDs, rebuilt: Set(tree.leaves().map(\.id))) + if !orphanedLeafIDs.isEmpty { + let count = orphanedLeafIDs.count + terminalStateLogger.error( + "Partial wake for tab \(tabId.rawValue): \(count) leaf/leaves failed to rebuild; requesting session kill." + ) + killZmxSessions(forSurfaceIDs: orphanedLeafIDs, includeRemote: true) + } + // A wake from a non-selection mutation leaves the tab hidden; re-arm here + // since the selection choke point never fired. + refreshTabVisibility() + onDormancyChanged?() + return tree + } let surface = createSurface( tabId: tabId, command: command, @@ -1006,6 +1170,9 @@ final class WorktreeTerminalState { let tree = SplitTree(view: surface) setTree(tree, for: tabId) setFocusedSurface(surface.id, for: tabId) + // A tab created while hidden (e.g. background worktree) has its tree only + // now, after the selection choke point already ran; schedule its timer. + refreshTabVisibility() return tree } @@ -1154,6 +1321,8 @@ final class WorktreeTerminalState { } func closeAllSurfaces() { + // Drain the grace timers first so nothing fires into a torn-down state. + cancelAllHibernationTimers() cancelPendingClose() let closingSurfaces = Array(surfaces.values) let closingSurfaceIDs = closingSurfaces.map(\.id) @@ -1164,10 +1333,22 @@ final class WorktreeTerminalState { discardSurfaceBookkeeping(for: surfaceID) } cleanupBlockingScriptLaunchDirectories() + // Drain hibernated tabs: the presence drop must include their frozen leaves + // so prune / quit clear the badges. Callers already kill these sessions off + // the dormant-inclusive `allSurfaceIDs` snapshot, so no kill happens here. + let dormantSurfaceIDs = dormantLeafSurfaceIDs + // Drop the surface states hibernation preserved for their unseen counters so + // a full teardown doesn't strand the worktree dot / total. + for surfaceID in dormantSurfaceIDs { + discardDormantLeafSurfaceState(for: surfaceID) + } + // `removeAll` fires the `dormantTabLayouts` didSet, reconciling the watchers + // to an empty set (stopping every one). + dormantTabLayouts.removeAll() trees.removeAll() surfaceGenerationByTab.removeAll() focusedSurfaceIdByTab.removeAll() - onSurfacesClosed?(Set(closingSurfaceIDs)) + onSurfacesClosed?(Set(closingSurfaceIDs).union(dormantSurfaceIDs)) let pendingKinds = Set(blockingScripts.values) blockingScripts.removeAll() lastBlockingScriptTabByKind.removeAll() @@ -1289,24 +1470,32 @@ final class WorktreeTerminalState { /// into the per-surface dict before invoking this so agents persist /// atomically with their owning surface and vanish on prune. func captureLayoutSnapshot( - agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]] = [:] + agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]]? = nil ) -> TerminalLayoutSnapshot? { guard !tabManager.tabs.isEmpty else { return nil } var tabSnapshots: [TerminalLayoutSnapshot.TabSnapshot] = [] for tab in tabManager.tabs { // Blocking-script tabs die with the app; persisting them would resurrect a dead session. if tab.isBlockingScript { continue } - guard let tree = trees[tab.id], let root = tree.root else { + let layout: TerminalLayoutSnapshot.LayoutNode + let focusedLeafIndex: Int + if let tree = trees[tab.id], let root = tree.root { + layout = captureLayoutNode(root, agentsBySurface: agentsBySurface ?? [:]) + let leaves = root.leaves() + let focusedId = focusedSurfaceIdByTab[tab.id] + focusedLeafIndex = + focusedId.flatMap { id in + leaves.firstIndex(where: { $0.id == id }) + } ?? 0 + } else if let dormant = dormantTabLayouts[tab.id] { + // A hibernated tab has no tree; refresh its frozen leaf agents from the + // live map so a busy->idle drift during dormancy still reaches disk. + layout = refreshDormantAgents(dormant.layout, agentsBySurface: agentsBySurface) + focusedLeafIndex = dormant.focusedLeafIndex ?? 0 + } else { layoutLogger.warning("Skipping tab \(tab.id.rawValue) during snapshot capture (no tree)") continue } - let layout = captureLayoutNode(root, agentsBySurface: agentsBySurface) - let leaves = root.leaves() - let focusedId = focusedSurfaceIdByTab[tab.id] - let focusedLeafIndex = - focusedId.flatMap { id in - leaves.firstIndex(where: { $0.id == id }) - } ?? 0 tabSnapshots.append( TerminalLayoutSnapshot.TabSnapshot( id: tab.id.rawValue, @@ -1374,6 +1563,37 @@ final class WorktreeTerminalState { } } + /// Rebuild a dormant layout's leaf agent records against `agentsBySurface`. + /// A nil map (no source wired) keeps frozen records unchanged. A non-nil map is + /// authoritative: a present leaf takes the refreshed records, an absent leaf is + /// cleared (its agent ended while dormant). + private func refreshDormantAgents( + _ node: TerminalLayoutSnapshot.LayoutNode, + agentsBySurface: [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]]? + ) -> TerminalLayoutSnapshot.LayoutNode { + guard let agentsBySurface else { return node } + switch node { + case .leaf(let surface): + guard let id = surface.id else { return node } + return .leaf( + TerminalLayoutSnapshot.SurfaceSnapshot( + id: id, + workingDirectory: surface.workingDirectory, + agents: agentsBySurface[id] ?? [] + ) + ) + case .split(let split): + return .split( + TerminalLayoutSnapshot.SplitSnapshot( + direction: split.direction, + ratio: split.ratio, + left: refreshDormantAgents(split.left, agentsBySurface: agentsBySurface), + right: refreshDormantAgents(split.right, agentsBySurface: agentsBySurface) + ) + ) + } + } + private func restoreFromSnapshot(_ snapshot: TerminalLayoutSnapshot, focusing: Bool) { guard !snapshot.tabs.isEmpty else { layoutLogger.warning("Attempted to restore empty layout snapshot, skipping restoration.") @@ -1384,8 +1604,6 @@ final class WorktreeTerminalState { pendingSetupScript = false for (index, tabSnapshot) in snapshot.tabs.enumerated() { - let firstLeafPwd = tabSnapshot.layout.firstLeaf.workingDirectory - let workingDir = firstLeafPwd.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } let context: ghostty_surface_context_e = index == 0 ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB let tabId = tabManager.createTab( @@ -1398,36 +1616,12 @@ final class WorktreeTerminalState { if let customTitle = tabSnapshot.customTitle { tabManager.setCustomTitle(tabId, title: customTitle) } - let surface = createSurface( + restoreTabLayout( tabId: tabId, - initialInput: nil, - workingDirectoryOverride: workingDir, - inheritingFromSurfaceId: nil, - context: context, - surfaceID: tabSnapshot.layout.firstLeaf.id, + layout: tabSnapshot.layout, + focusedLeafIndex: tabSnapshot.focusedLeafIndex, + context: context ) - let tree = SplitTree(view: surface) - setTree(tree, for: tabId) - setFocusedSurface(surface.id, for: tabId) - - // Recursively restore splits. - restoreLayoutNode(tabSnapshot.layout, anchor: surface, tabId: tabId) - - // Log if partial restoration produced fewer panes than expected. - let leaves = trees[tabId]?.root?.leaves() ?? [] - let expectedLeaves = tabSnapshot.layout.leafCount - if leaves.count != expectedLeaves { - layoutLogger.warning( - "Partial restore for tab '\(tabSnapshot.title)': expected \(expectedLeaves) panes, got \(leaves.count)" - ) - } - - // Focus the correct leaf. - let focusedIndex = max(0, min(tabSnapshot.focusedLeafIndex, leaves.count - 1)) - if focusedIndex < leaves.count { - setFocusedSurface(leaves[focusedIndex].id, for: tabId) - } - onTabCreated?() } @@ -1459,6 +1653,45 @@ final class WorktreeTerminalState { rebuildUnseenCounters() } + /// Rebuilds a tab's split tree from a snapshot layout using the ORIGINAL + /// surface UUIDs (so `zmx attach` reattaches), restores the split structure, + /// and clamps focus to `focusedLeafIndex`. + private func restoreTabLayout( + tabId: TerminalTabID, + layout: TerminalLayoutSnapshot.LayoutNode, + focusedLeafIndex: Int, + context: ghostty_surface_context_e + ) { + let firstLeafPwd = layout.firstLeaf.workingDirectory + let workingDir = firstLeafPwd.flatMap { URL(filePath: $0, directoryHint: .isDirectory) } + let surface = createSurface( + tabId: tabId, + initialInput: nil, + workingDirectoryOverride: workingDir, + inheritingFromSurfaceId: nil, + context: context, + surfaceID: layout.firstLeaf.id, + ) + let tree = SplitTree(view: surface) + setTree(tree, for: tabId) + setFocusedSurface(surface.id, for: tabId) + + restoreLayoutNode(layout, anchor: surface, tabId: tabId) + + let leaves = trees[tabId]?.root?.leaves() ?? [] + let expectedLeaves = layout.leafCount + if leaves.count != expectedLeaves { + layoutLogger.warning( + "Partial restore for tab \(tabId.rawValue): expected \(expectedLeaves) panes, got \(leaves.count)" + ) + } + + let focusedIndex = max(0, min(focusedLeafIndex, leaves.count - 1)) + if focusedIndex < leaves.count { + setFocusedSurface(leaves[focusedIndex].id, for: tabId) + } + } + private func restoreLayoutNode( _ node: TerminalLayoutSnapshot.LayoutNode, anchor: GhosttySurfaceView, @@ -1749,7 +1982,11 @@ final class WorktreeTerminalState { wireSurfaceCallbacks(view: view, tabId: tabId) surfaces[view.id] = view surfaceLaunchMetadata[view.id] = SurfaceLaunchMetadata(usesZmx: launch.usesZmx, context: context) - surfaceStates[view.id] = WorktreeSurfaceState() + // Preserve an existing surface state (a woken dormant leaf re-adopts its + // unseen counter under the original UUID); mint fresh only for a new surface. + if surfaceStates[view.id] == nil { + surfaceStates[view.id] = WorktreeSurfaceState() + } return view } @@ -1869,6 +2106,17 @@ final class WorktreeTerminalState { surfaces[view.id] === view } + // A surface this worktree owns: a live view or a dormant leaf whose session the + // watcher tails. OSC ingest guards accept both so a dark tab's recovered signals + // land, while a truly unknown id (e.g. a just-closed surface) is still dropped. + private func isKnownSurface(_ surfaceID: UUID) -> Bool { + surfaces[surfaceID] != nil || isDormantSurface(surfaceID) + } + + private func isDormantSurface(_ surfaceID: UUID) -> Bool { + dormantTabLayouts.values.contains { $0.layout.leafSurfaceIDs.contains(surfaceID) } + } + // The bridge state of the focused surface in the selected tab, if any. Used to // resolve the window tint from the focused surface's OSC 11 background. func focusedSurfaceState() -> GhosttySurfaceState? { @@ -1899,7 +2147,7 @@ final class WorktreeTerminalState { id: id, metadata: metadata, surfaceID: surfaceID, - surfaceExists: surfaces[surfaceID] != nil + surfaceExists: isKnownSurface(surfaceID) ) { case .success(let event): onAgentHookEvent?(event) @@ -1939,13 +2187,36 @@ final class WorktreeTerminalState { agent: signal.agent, event: signal.eventRawValue, surfaceID: surfaceID, pid: signal.pid)) } + /// Splits a raw OSC 3008 payload (`=[;]`) into context id + /// and raw metadata, mirroring libghostty's context-signal parser for the + /// dormant channel that bypasses it. Returns nil without a `start=` / `end=` + /// prefix or a spec-valid id (1-64 printable ASCII bytes). + nonisolated static func contextSignalFields(payload: String) -> (id: String, metadata: String)? { + let rest: Substring + if payload.hasPrefix("start=") { + rest = payload.dropFirst("start=".count) + } else if payload.hasPrefix("end=") { + rest = payload.dropFirst("end=".count) + } else { + return nil + } + guard !rest.isEmpty else { return nil } + let idEnd = rest.firstIndex(of: ";") ?? rest.endIndex + let id = rest[.. Bool { + let hadUnseen = (surfaceStates[surfaceID]?.unseenNotificationCount ?? 0) > 0 + surfaceStates.removeValue(forKey: surfaceID) + guard hadUnseen else { return false } + for index in notifications.indices where notifications[index].surfaceID == surfaceID { + notifications[index].isRead = true + } + return true + } + /// Tears down persistent zmx sessions for surfaces the user just closed. /// `isBundled` (not `executableURL`) is the gate so sessions created on a /// previous under-budget launch still tear down when this launch exceeds the @@ -2422,6 +2712,9 @@ final class WorktreeTerminalState { for (tabId, tree) in trees where tree.find(id: surfaceID) != nil { return tabId } + for (tabId, dormant) in dormantTabLayouts where dormant.layout.leafSurfaceIDs.contains(surfaceID) { + return tabId + } return nil } @@ -2562,6 +2855,15 @@ final class WorktreeTerminalState { /// not fire the callback. private func emitTabProjection(for tabId: TerminalTabID) { guard let tree = trees[tabId] else { + // A hibernated tab is still in `tabManager`; project from its frozen + // leaves rather than signalling removal. + if let dormant = dormantTabLayouts[tabId] { + emitDormantTabProjection(for: tabId, dormant: dormant) + return + } + // Removal fires only for a tab genuinely gone from `tabManager`; a tab + // still present with no tree is mid-creation and settles once the tree lands. + guard !hasTab(tabId) else { return } surfaceGenerationByTab.removeValue(forKey: tabId) if lastTabProjections.removeValue(forKey: tabId) != nil { onTabRemoved?(tabId) @@ -2569,24 +2871,56 @@ final class WorktreeTerminalState { return } let surfaceIDs = tree.leaves().map(\.id) - let unseenCount = surfaceIDs.reduce(0) { $0 + (surfaceStates[$1]?.unseenNotificationCount ?? 0) } let projection = WorktreeTabProjection( tabID: tabId, surfaceIDs: surfaceIDs, activeSurfaceID: focusedSurfaceIdByTab[tabId], - unseenNotificationCount: unseenCount, + unseenNotificationCount: unseenNotificationCount(inSurfaces: surfaceIDs), isSplitZoomed: tree.zoomed != nil, surfaceGeneration: surfaceGenerationByTab[tabId, default: 0], ) - guard lastTabProjections[tabId] != projection else { return } - lastTabProjections[tabId] = projection + commitTabProjection(projection) + } + + /// Projection for a hibernated tab: surfaces and unseen count come from the + /// frozen leaves, zoom and focus from the stashed indices, and `isDormant` is + /// set so the tab bar can render the dormancy accessory. + private func emitDormantTabProjection(for tabId: TerminalTabID, dormant: DormantTabLayout) { + let surfaceIDs = dormant.layout.leafSurfaceIDs + let activeSurfaceID = dormant.focusedLeafIndex.flatMap { index in + surfaceIDs.indices.contains(index) ? surfaceIDs[index] : nil + } + let projection = WorktreeTabProjection( + tabID: tabId, + surfaceIDs: surfaceIDs, + activeSurfaceID: activeSurfaceID, + unseenNotificationCount: unseenNotificationCount(inSurfaces: surfaceIDs), + isSplitZoomed: dormant.zoomedSurfaceID != nil, + surfaceGeneration: surfaceGenerationByTab[tabId, default: 0], + isDormant: true, + ) + commitTabProjection(projection) + } + + /// Sum of the surfaces' outstanding unread counters, for the projection badge. + /// Counter-based, not a log scan: the capped notification log would undercount. + /// Dormant-safe while hibernated leaves keep their `surfaceStates` entry. + private func unseenNotificationCount(inSurfaces surfaceIDs: [UUID]) -> Int { + surfaceIDs.reduce(0) { $0 + (surfaceStates[$1]?.unseenNotificationCount ?? 0) } + } + + /// Stores the projection and fires `onTabProjectionChanged` only when it drifts + /// from the cached value, keeping a no-op rebuild idempotent. + private func commitTabProjection(_ projection: WorktreeTabProjection) { + guard lastTabProjections[projection.tabID] != projection else { return } + lastTabProjections[projection.tabID] = projection onTabProjectionChanged?(projection) } /// Recompute every tab's projection. Used after notification-list mutations /// that may span multiple tabs (mark-all-read, dismiss-all). private func emitAllTabProjections() { - for tabId in trees.keys { + for tabId in Set(trees.keys).union(dormantTabLayouts.keys) { emitTabProjection(for: tabId) } } @@ -2675,7 +3009,7 @@ final class WorktreeTerminalState { } let isExplicitClose = pendingExplicitSurfaceCloseIDs.contains(view.id) if isExplicitClose, pendingCloseConfirmation != nil { - if pendingCloseConfirmation != .surface(view.id) { + if pendingCloseConfirmation?.target != .surface(view.id) { pendingExplicitSurfaceCloseIDs.remove(view.id) } return @@ -2833,6 +3167,7 @@ final class WorktreeTerminalState { killZmxSessions(forSurfaceIDs: [view.id], includeRemote: includeRemoteSession) } if newTree.isEmpty { + cancelHibernationTimer(for: tabId) removeFromPendingClose(tabId: tabId) trees.removeValue(forKey: tabId) focusedSurfaceIdByTab.removeValue(forKey: tabId) @@ -2938,13 +3273,337 @@ final class WorktreeTerminalState { return maxIndex + 1 } + // MARK: - Hibernation + + /// Grace window a tab must stay hidden before it hibernates. + private static let hibernationGraceWindow: Duration = .seconds(5 * 60) + + /// Marks whether this state's worktree is selected and re-diffs visibility. + func setWorktreeSelected(_ selected: Bool) { + guard isWorktreeSelected != selected else { return } + isWorktreeSelected = selected + refreshTabVisibility() + } + + /// A tab is hidden unless it is the selected tab of the selected worktree. + private func isTabHidden(_ tabId: TerminalTabID) -> Bool { + !(isWorktreeSelected && tabManager.selectedTabId == tabId) + } + + /// Diffs the hidden set against the scheduled timers: cancel for tabs that + /// became visible or vanished, schedule for newly hidden live tabs. A treeless + /// live tab (mid-creation) is scheduled once its tree lands via `splitTree`. + func refreshTabVisibility() { + let liveTabIDs = Set(tabManager.tabs.map(\.id)) + for scheduledTabId in Array(hibernationTimers.keys) + where !liveTabIDs.contains(scheduledTabId) || !isTabHidden(scheduledTabId) { + cancelHibernationTimer(for: scheduledTabId) + } + for tab in tabManager.tabs { + guard isTabHidden(tab.id), trees[tab.id] != nil else { continue } + guard hibernationTimers[tab.id] == nil else { continue } + scheduleHibernationTimer(for: tab.id) + } + } + + /// Applies a flip of the hibernation Beta flag. Enabling re-arms grace timers + /// for every currently hidden live tab; disabling cancels all pending timers so + /// a mid-window flip never hibernates. Already-dormant tabs stay dormant. + func applyHibernationEnabled(_ enabled: Bool) { + isHibernationEnabled = enabled + if enabled { + refreshTabVisibility() + } else { + cancelAllHibernationTimers() + } + } + + private func scheduleHibernationTimer(for tabId: TerminalTabID) { + // Inert while the Beta feature is off; a later opt-in re-arms via the + // visibility funnel, so no timer is silently stranded. + guard isHibernationEnabled else { return } + let clock = hibernationClock + hibernationTimers[tabId] = Task { [weak self] in + do { + try await clock.sleep(for: Self.hibernationGraceWindow) + } catch { + return + } + guard !Task.isCancelled, let self else { return } + self.handleHibernationTimerFired(for: tabId) + } + } + + /// Cancels a tab's timer (the tab is now visible, gone, or hibernated). + private func cancelHibernationTimer(for tabId: TerminalTabID) { + hibernationTimers.removeValue(forKey: tabId)?.cancel() + loggedIneligibleDeferralTabs.remove(tabId) + } + + private func cancelAllHibernationTimers() { + for task in hibernationTimers.values { task.cancel() } + hibernationTimers.removeAll() + loggedIneligibleDeferralTabs.removeAll() + } + + /// The fire path runs in ONE synchronous main-actor turn: re-check the tab is + /// still hidden and eligible, then hibernate or re-arm. No awaits between the + /// check and teardown, so a concurrent selection can't slip a visible tab into + /// hibernation. An actively-working agent does not block: its zmx session keeps + /// the process alive and the dormant watcher keeps notifications lossless. + private func handleHibernationTimerFired(for tabId: TerminalTabID) { + hibernationTimers.removeValue(forKey: tabId) + // Re-check at fire time so a flip to off mid-window never hibernates. + guard isHibernationEnabled else { return } + guard hasTab(tabId), isTabHidden(tabId) else { + // Tab gone or now visible: nothing re-arms it (becoming hidden reschedules + // via the visibility funnel). + return + } + guard canHibernate(tabId: tabId) else { + // Still hidden but momentarily ineligible (e.g. a non-zmx leaf); re-arm so + // a later eligibility flip still hibernates instead of wedging forever. + // Log once until the tab becomes eligible or visible again, so a permanently + // ineligible hidden tab doesn't spam every grace-window re-fire. + if loggedIneligibleDeferralTabs.insert(tabId).inserted { + terminalStateLogger.debug("Hibernation for tab \(tabId.rawValue) deferred: not currently eligible; re-armed.") + } + scheduleHibernationTimer(for: tabId) + return + } + loggedIneligibleDeferralTabs.remove(tabId) + performHibernation(tabId) + } + + /// Resolves the frozen agent records, warning once and returning nil when the + /// closure is unwired so callers distinguish "no agents" from "no wiring". + private func resolvedAgentsBySurface() -> [UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]]? { + guard let agents = hibernationAgentsBySurface?() else { + warnMissingAgentsClosureOnce() + return nil + } + return agents + } + + /// Warns once per state instance when the agent-records closure is unwired, so + /// broken wiring is not silently read as "no agents". + private func warnMissingAgentsClosureOnce() { + guard !hasLoggedMissingAgentsClosure else { return } + hasLoggedMissingAgentsClosure = true + terminalStateLogger.warning( + "hibernationAgentsBySurface closure is unwired for worktree \(worktree.id); treating as no agents.") + } + + /// Frozen leaves that failed to rebuild on wake: present in the stashed layout + /// but absent from the rebuilt tree, so their zmx sessions are orphaned. + nonisolated static func orphanedWakeLeafIDs(expected: [UUID], rebuilt: Set) -> [UUID] { + expected.filter { !rebuilt.contains($0) } + } + + /// True when an OSC 9 payload is a ConEmu subcommand (its first `;`-separated + /// field is an integer 1...12), not an iTerm2 notification body. Mirrors + /// libghostty's OSC-9 ConEmu-vs-notification split. + nonisolated static func isConEmuOSC9Payload(_ payload: String) -> Bool { + guard let subcommand = Int(payload.prefix { $0 != ";" }) else { return false } + return (1...12).contains(subcommand) + } + + /// Whether a tab may hibernate. A blocking-script tab is excluded (it dies with + /// the app), and every leaf must be zmx-wrapped or teardown would kill a shell + /// that can't reattach. + func canHibernate(tabId: TerminalTabID) -> Bool { + guard let tree = trees[tabId], tree.root != nil else { return false } + guard !tabManager.isBlockingScript(tabId) else { return false } + // An alert is waiting on this tab; hibernating would tear its target down + // and drop the user's close request without a trace. + guard !hasPendingCloseConfirmation(forTabID: tabId) else { return false } + return tree.leaves().allSatisfy { surfaceLaunchMetadata[$0.id]?.usesZmx == true } + } + + private func hasPendingCloseConfirmation(forTabID tabId: TerminalTabID) -> Bool { + switch pendingCloseConfirmation?.target { + case .surface(let surfaceID): tabID(containing: surfaceID) == tabId + case .tabs(let tabIDs): tabIDs.contains(tabId) + case nil: false + } + } + + /// Hibernates a tab: freeze the layout, tear down the leaf surfaces WITHOUT + /// killing their zmx sessions or dropping presence, and keep the tab in + /// `tabManager` so its row, title, and unseen count survive. Surfaces return + /// with the same UUIDs on wake so `zmx attach` reattaches. + func hibernateTab(_ tabId: TerminalTabID) { + guard canHibernate(tabId: tabId) else { return } + performHibernation(tabId) + } + + /// Explicit wake for CLI / deeplink / unread-jump call sites. Routes through + /// the single `splitTree` wake funnel. + func wakeTab(_ tabId: TerminalTabID) { + _ = splitTree(for: tabId) + } + + /// Shared teardown for `hibernateTab` and the DEBUG bypass seam. Captures the + /// dormant layout, drops the tree plus per-tab focus / generation and surface + /// bookkeeping, and cancels the manager's idle hooks without a presence drop. + private func performHibernation(_ tabId: TerminalTabID) { + guard let tree = trees[tabId], let root = tree.root else { return } + // The tab is going dormant; a leftover timer (manual hibernate path) must not + // survive to fire into the dormant entry. + cancelHibernationTimer(for: tabId) + let leaves = root.leaves() + let leafIDs = leaves.map(\.id) + // Freeze the live agent records into the layout so a snapshot persisted while + // dormant keeps its presence badges and image-paste routing across relaunch. + let layout = captureLayoutNode(root, agentsBySurface: resolvedAgentsBySurface() ?? [:]) + let focusedId = focusedSurfaceIdByTab[tabId] + let focusedLeafIndex = focusedId.flatMap { id in leaves.firstIndex(where: { $0.id == id }) } + // The assignment fires the didSet, starting the dormant-session watchers. + dormantTabLayouts[tabId] = DormantTabLayout( + layout: layout, + focusedLeafIndex: focusedLeafIndex, + zoomedSurfaceID: tree.zoomed?.leftmostLeaf().id + ) + // Teardown in one turn: the `surfaces[id] === view` guards in the close / + // unexpected-close handlers make any late callback or in-flight probe inert. + trees.removeValue(forKey: tabId) + surfaceGenerationByTab.removeValue(forKey: tabId) + focusedSurfaceIdByTab.removeValue(forKey: tabId) + for leaf in leaves { + leaf.closeSurface() + discardSurfaceBookkeeping(for: leaf.id, preserveSurfaceState: true) + } + onSurfacesHibernated?(Set(leafIDs)) + emitTabProjection(for: tabId) + // The torn-down tree emits no more OSC progress, so clear the stripe and + // re-derive task status without this tab (dormant progress is ConEmu-dropped). + emitTabProgressDisplay(for: tabId) + emitTaskStatusIfChanged() + onDormancyChanged?() + } + + /// Reconciles the passive session watchers against the current dormant leaf + /// set. Stopping a watcher closes its socket, so on an explicit close this runs + /// before the session kill. + private func syncDormantSessionWatchers() { + let dormantSurfaceIDs = Set(dormantLeafSurfaceIDs) + dormantSessionWatchers.reconcile(dormantSurfaceIDs: dormantSurfaceIDs) + } + + /// Single ingress for a dormant session's OSC signal, routed into the same + /// notification / presence / title handlers a live surface uses. Accepts a + /// live-or-dormant surface (wake/close overlap), drops unknown; delivered once. + private func handleDormantOSCSequence(surfaceID: UUID, sequence: ZmxOSCSequence) { + guard isKnownSurface(surfaceID) else { return } + switch sequence.code { + case 9: + // OSC 9 is shared by iTerm2 notifications and ConEmu subcommands (progress, + // sleep, ...); a leading small-integer field marks a ConEmu form, not a body. + guard let body = sequence.payloadString else { + logDroppedNonUTF8DormantOSC(surfaceID: surfaceID, code: sequence.code) + return + } + guard !Self.isConEmuOSC9Payload(body) else { + terminalStateLogger.debug("Dropped ConEmu-shaped OSC 9 for dormant surface \(surfaceID).") + return + } + handleAgentOSCNotification(title: "", body: body, surfaceID: surfaceID) + case 3008: + guard let payload = sequence.payloadString else { + logDroppedNonUTF8DormantOSC(surfaceID: surfaceID, code: sequence.code) + return + } + guard let fields = Self.contextSignalFields(payload: payload) else { return } + handleContextSignal(surfaceID: surfaceID, id: fields.id, metadata: fields.metadata) + case 0, 2: + guard let title = sequence.payloadString else { + logDroppedNonUTF8DormantOSC(surfaceID: surfaceID, code: sequence.code) + return + } + updateDormantTabTitle(surfaceID: surfaceID, title: title) + default: + break + } + } + + private func logDroppedNonUTF8DormantOSC(surfaceID: UUID, code: Int) { + terminalStateLogger.debug("Dropped dormant OSC \(code) with non-UTF-8 payload for surface \(surfaceID).") + } + + /// Updates a dormant tab's row title from an OSC 0/2 on its focused leaf, + /// mirroring the live `updateTabTitle` where only the focused surface drives + /// the row. A now-live surface is skipped: its own title pipeline is authoritative. + private func updateDormantTabTitle(surfaceID: UUID, title: String) { + guard let tabId = tabID(containing: surfaceID), + let dormant = dormantTabLayouts[tabId] + else { return } + let focusedLeaf = dormant.focusedLeafIndex.flatMap { index in + dormant.layout.leafSurfaceIDs.indices.contains(index) ? dormant.layout.leafSurfaceIDs[index] : nil + } + guard focusedLeaf == surfaceID else { return } + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + tabManager.updateTitle(tabId, title: trimmed) + } + + /// Rebuilds a hibernated tab's tree from its frozen layout via the shared + /// restore core and re-applies zoom. No generation bump (the nil->tree + /// transition invalidates by itself) and no AppKit first-responder calls, since + /// this may run from a view-body evaluation. + private func wakeDormantTab( + _ tabId: TerminalTabID, + dormant: DormantTabLayout + ) -> SplitTree { + // Re-derive the anchor context from the live tab order (mirrors + // `restoreFromSnapshot`), so a since-closed first tab wakes the now-first tab + // as WINDOW instead of replaying a context frozen at hibernate. + let isFirstTab = tabManager.tabs.first?.id == tabId + let context: ghostty_surface_context_e = + isFirstTab ? GHOSTTY_SURFACE_CONTEXT_WINDOW : GHOSTTY_SURFACE_CONTEXT_TAB + restoreTabLayout( + tabId: tabId, + layout: dormant.layout, + focusedLeafIndex: dormant.focusedLeafIndex ?? 0, + context: context + ) + guard let tree = trees[tabId] else { return SplitTree() } + if let zoomedID = dormant.zoomedSurfaceID, + let zoomedSurface = surfaces[zoomedID], + let node = tree.root?.node(view: zoomedSurface) + { + setTree(tree.settingZoomed(node), for: tabId) + } + // The unseen counters rode through hibernation on the preserved + // `surfaceStates`, re-adopted here under the original UUIDs; wake neither + // re-derives nor clears them. + return trees[tabId] ?? SplitTree() + } + + /// Explicit close of a hibernated tab: kill its frozen zmx sessions, drop the + /// presence records, and purge the dormant entry synchronously so an orphan + /// can't feed the next-launch reaper. No live surfaces exist to close. + private func removeDormantTab(_ tabId: TerminalTabID) { + // `removeValue` fires the didSet, stopping the leaf watchers (closing their + // sockets) before the session kill below. + guard let dormant = dormantTabLayouts.removeValue(forKey: tabId) else { return } + let leafIDs = dormant.layout.leafSurfaceIDs + surfaceGenerationByTab.removeValue(forKey: tabId) + var clearedUnseen = false + for leafID in leafIDs { + clearedUnseen = discardDormantLeafSurfaceState(for: leafID) || clearedUnseen + } + killZmxSessions(forSurfaceIDs: leafIDs, includeRemote: true) + onSurfacesClosed?(Set(leafIDs)) + if clearedUnseen { onNotificationIndicatorChanged?() } + if lastTabProjections.removeValue(forKey: tabId) != nil { + onTabRemoved?(tabId) + } + } + #if DEBUG - /// Test-only seam for bulk-assigning the notifications log. Fans - /// `emitAllTabProjections()` so `lastTabProjections` stays in sync with - /// the raw log; production code must go through the per-event helpers - /// (`appendHookNotification`, `markNotificationsRead`, etc.) which already - /// emit. Gated `#if DEBUG` so release builds genuinely can't reach the - /// projection-bypass path. + /// Test-only seam for bulk-assigning the notifications log, fanning + /// `emitAllTabProjections()` so `lastTabProjections` stays in sync with the + /// raw log. Production writes go through the per-event helpers, which emit. func setNotificationsForTesting(_ list: [WorktreeTerminalNotification]) { notifications = list rebuildUnseenCounters() @@ -2958,5 +3617,39 @@ final class WorktreeTerminalState { surfaceStates[surfaceID] = state } + /// Test-only seam that hibernates a tab while bypassing `canHibernate`, so + /// tests can exercise the dormant path without a live zmx executable making + /// every surface eligible. Shares `performHibernation` with production. + func hibernateTabForTesting(_ tabId: TerminalTabID) { + performHibernation(tabId) + } + + /// Tabs with a live grace timer, so visibility / cancel behavior is + /// assertable without reaching into the private dict. + var scheduledHibernationTabsForTesting: Set { Set(hibernationTimers.keys) } + + /// Surface ids currently tailed by a dormant-session watcher, so the + /// `watched == dormant leaves` invariant is assertable without a live socket. + var watchedDormantSurfaceIDsForTesting: Set { dormantSessionWatchers.watchedSurfaceIDs } + + /// Resolved surface context (WINDOW vs TAB) for a live surface, so the + /// wake-time context re-derivation is assertable. + func surfaceContextForTesting(_ surfaceID: UUID) -> ghostty_surface_context_e? { + surfaceLaunchMetadata[surfaceID]?.context + } + + /// Drives the fire path directly so the fire-time re-check backstops (tab + /// gone, tab visible) are assertable without a real grace-window elapse. + func fireHibernationTimerForTesting(_ tabId: TerminalTabID) { + handleHibernationTimerFired(for: tabId) + } + + /// Drives a dormant-session OSC straight into the ingest, so the watcher's + /// delivery path (notifications / presence / titles, and the wake-overlap + /// acceptance rule) is assertable without a live socket. + func deliverDormantOSCForTesting(surfaceID: UUID, sequence: ZmxOSCSequence) { + handleDormantOSCSequence(surfaceID: surfaceID, sequence: sequence) + } + #endif } diff --git a/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift b/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift index 408f9c32b..ee7179ee8 100644 --- a/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift +++ b/supacode/Features/Terminal/Reducer/TerminalTabFeature.swift @@ -28,6 +28,8 @@ struct TerminalTabFeature { var isSplitZoomed: Bool = false /// Monotonic invalidation token for same-UUID surface view replacement. var surfaceGeneration = 0 + /// True while the tab's surfaces are hibernated. Drives the tab-bar sleep accessory. + var isDormant: Bool = false /// Per-tab agent snapshot pushed by `AppFeature.agentPresenceFanOutEffect`. /// Leaf reads `state.agents` instead of iterating worktree-wide presence on /// every storm tick. @@ -63,6 +65,9 @@ struct TerminalTabFeature { if state.surfaceGeneration != projection.surfaceGeneration { state.surfaceGeneration = projection.surfaceGeneration } + if state.isDormant != projection.isDormant { + state.isDormant = projection.isDormant + } return .none case .agentSnapshotChanged(let agents): diff --git a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index d60f20abf..af8638a89 100644 --- a/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/supacode/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -14,6 +14,7 @@ struct TerminalTabLabelView: View { var body: some View { HStack(spacing: TerminalTabBarMetrics.contentSpacing) { + TerminalTabDormantIndicator(tabStore: tabStore) TerminalTabAgentBadge(tabStore: tabStore) if let icon = tab.icon { Image(systemName: icon) @@ -54,6 +55,28 @@ private struct TerminalTabTitleLabel: View, Equatable { } } +/// Leading sleep marker for a hibernated tab, read off the per-tab scoped store +/// so wake clears it via the normal projection update without touching siblings. +private struct TerminalTabDormantIndicator: View { + let tabStore: StoreOf + + var body: some View { + if tabStore.state.isDormant { + // Semibold compensates for the zzz glyph's thin strokes (see the sidebar twin). + Image(systemName: "zzz") + .imageScale(.small) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .frame( + width: TerminalTabBarMetrics.closeButtonSize, + height: TerminalTabBarMetrics.closeButtonSize + ) + .accessibilityLabel("Hibernated tab") + .help("Hibernated to save resources. Select to reconnect.") + } + } +} + /// Reads agent presence off the per-tab scoped store, so an agent storm on /// tab B invalidates only tab B's badge leaf. Mirrors sidebar's /// `RunningAgentsBadgeContent` pattern with the inner Equatable wrapper. diff --git a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift index fa798cf47..c62ecce7f 100644 --- a/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift +++ b/supacode/Features/Terminal/Views/WorktreeTerminalTabsView.swift @@ -89,7 +89,7 @@ struct WorktreeTerminalTabsView: View { state.confirmPendingClose(pending) } }, - message: { _ in Text(WorktreeTerminalState.PendingCloseConfirmation.message) } + message: { pending in Text(pending.message) } ) .background( WindowFocusObserverView { activity in diff --git a/supacodeTests/AppFeatureSettingsChangedTests.swift b/supacodeTests/AppFeatureSettingsChangedTests.swift index b49cb9896..e30054d95 100644 --- a/supacodeTests/AppFeatureSettingsChangedTests.swift +++ b/supacodeTests/AppFeatureSettingsChangedTests.swift @@ -96,6 +96,40 @@ struct AppFeatureSettingsChangedTests { #expect(store.state.lastKnownAgentPresenceBadgesEnabled == true) } + @Test(.dependencies) func togglingHibernationFlagFansOutToTerminalClient() async { + let sentEnabled = LockIsolated<[Bool]>([]) + var appState = AppFeature.State( + repositories: RepositoriesFeature.State(), + settings: SettingsFeature.State() + ) + appState.lastKnownTerminalHibernationEnabled = true + + let store = TestStore(initialState: appState) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in + guard case .setTerminalHibernationEnabled(let enabled) = command else { return } + sentEnabled.withValue { $0.append(enabled) } + } + } + store.exhaustivity = .off + + var settings = GlobalSettings.default + settings.terminalHibernationEnabled = false + + // The flip fires the command once with the new value and tracks the flag. + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + #expect(sentEnabled.value == [false]) + #expect(store.state.lastKnownTerminalHibernationEnabled == false) + + // A settingsChanged that does not flip hibernation emits no further command. + await store.send(.settings(.delegate(.settingsChanged(settings)))) + await store.finish() + #expect(sentEnabled.value == [false]) + #expect(store.state.lastKnownTerminalHibernationEnabled == false) + } + @Test(.dependencies) func focusingASurfaceClearsTheStatesParkedOnIt() async { let rootURL = URL(fileURLWithPath: "/tmp/repo") let worktree = Worktree( diff --git a/supacodeTests/GhosttySurfaceBridgeTests.swift b/supacodeTests/GhosttySurfaceBridgeTests.swift index e9cfe922a..b7f15b97d 100644 --- a/supacodeTests/GhosttySurfaceBridgeTests.swift +++ b/supacodeTests/GhosttySurfaceBridgeTests.swift @@ -271,8 +271,11 @@ struct GhosttySurfaceBridgeTests { bridge.ingestProgressReport(state: GHOSTTY_PROGRESS_STATE_INDETERMINATE, value: nil) // No further reports: the stale window synthesizes a REMOVE and tears down - // the driver. - await settleThenAdvance(clock, by: .milliseconds(100)) + // the driver. The stale watch can register its sleep after the first + // advance under load, so keep advancing until the REMOVE lands. + for _ in 0..<50 where bridge.state.progressState != nil { + await settleThenAdvance(clock, by: .milliseconds(100)) + } #expect(bridge.state.progressState == nil) // A report after the stale REMOVE must re-arm the driver, not freeze. diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index 10dabba99..83866ec23 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -115,6 +115,21 @@ struct SettingsFeatureTests { expectNoDifference(settingsFile.global, expectedSettings) } + @Test(.dependencies) func togglingTerminalHibernationPersistsChanges() async { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global = .default } + + let store = TestStore(initialState: SettingsFeature.State()) { + SettingsFeature() + } + + await store.send(.binding(.set(\.terminalHibernationEnabled, false))) { + $0.terminalHibernationEnabled = false + } + await store.receive(\.delegate.settingsChanged) + #expect(settingsFile.global.terminalHibernationEnabled == false) + } + @Test(.dependencies) func confirmCloseSurfacePersistsChanges() async { var initialSettings = GlobalSettings.default initialSettings.confirmCloseSurface = true diff --git a/supacodeTests/SettingsFilePersistenceTests.swift b/supacodeTests/SettingsFilePersistenceTests.swift index 1249b9ffa..5531839cc 100644 --- a/supacodeTests/SettingsFilePersistenceTests.swift +++ b/supacodeTests/SettingsFilePersistenceTests.swift @@ -452,6 +452,29 @@ struct SettingsFilePersistenceTests { #expect(settings.global.remoteSessionPersistenceEnabled == true) } + @Test(.dependencies) func decodesMissingTerminalHibernationEnabledAsTrue() throws { + let legacy = LegacySettingsFile( + global: LegacyGlobalSettings( + appearanceMode: .dark, + updatesAutomaticallyCheckForUpdates: false, + updatesAutomaticallyDownloadUpdates: true + ), + repositories: [:] + ) + let data = try JSONEncoder().encode(legacy) + let storage = MutableTestStorage(initialData: data) + + let settings: SettingsFile = withDependencies { + $0.settingsFileStorage = storage.storage + } operation: { + @Shared(.settingsFile) var settings: SettingsFile + return settings + } + + // The Beta feature defaults on, so a pre-feature file decodes to on. + #expect(settings.global.terminalHibernationEnabled == true) + } + @Test(.dependencies) func decodesMissingAppVisibilityAsDefault() throws { // A file predating the menu bar feature falls through to the default, which // now shows the menu bar too. diff --git a/supacodeTests/SidebarItemFeatureTests.swift b/supacodeTests/SidebarItemFeatureTests.swift index e2c867d2a..b547e3edf 100644 --- a/supacodeTests/SidebarItemFeatureTests.swift +++ b/supacodeTests/SidebarItemFeatureTests.swift @@ -174,6 +174,23 @@ struct SidebarItemFeatureTests { } } + @Test func terminalProjectionTogglesAllTabsDormant() async { + let store = TestStore(initialState: makeState(name: "feature")) { + SidebarItemFeature() + } + // Every tab hibernated: the row lights its sleep marker. + await store.send(.terminalProjectionChanged(makeProjection(allTabsDormant: true))) { + $0.hasTerminalProjection = true + $0.allTabsDormant = true + } + // Same value: no-op. + await store.send(.terminalProjectionChanged(makeProjection(allTabsDormant: true))) + // A tab wakes: the marker clears. + await store.send(.terminalProjectionChanged(makeProjection(allTabsDormant: false))) { + $0.allTabsDormant = false + } + } + // MARK: - Stale-PR guard. @Test func pullRequestChangedDropsResultWhenBranchHasFlipped() async { @@ -319,14 +336,16 @@ struct SidebarItemFeatureTests { isProgressBusy: Bool = false, hasUnseenNotifications: Bool = false, notifications: IdentifiedArrayOf = [], - runningScripts: IdentifiedArrayOf = [] + runningScripts: IdentifiedArrayOf = [], + allTabsDormant: Bool = false ) -> WorktreeRowProjection { WorktreeRowProjection( surfaceIDs: surfaceIDs, isProgressBusy: isProgressBusy, hasUnseenNotifications: hasUnseenNotifications, notifications: notifications, - runningScripts: runningScripts + runningScripts: runningScripts, + allTabsDormant: allTabsDormant ) } } diff --git a/supacodeTests/TerminalTabFeatureTests.swift b/supacodeTests/TerminalTabFeatureTests.swift index 108d0e51a..cffe18ef5 100644 --- a/supacodeTests/TerminalTabFeatureTests.swift +++ b/supacodeTests/TerminalTabFeatureTests.swift @@ -115,6 +115,49 @@ struct TerminalTabFeatureTests { } } + @Test func projectionChangedTogglesDormantIndependently() async { + let tabID = TerminalTabID(rawValue: UUID()) + let surface = UUID() + let store = TestStore( + initialState: TerminalTabFeature.State( + id: tabID, + worktreeID: "/tmp/repo", + surfaceIDs: [surface], + activeSurfaceID: surface, + unseenNotificationCount: 0 + ) + ) { TerminalTabFeature() } + + // Hibernate: the dormancy flag flows through so the tab bar shows the marker. + await store.send( + .projectionChanged( + WorktreeTabProjection( + tabID: tabID, + surfaceIDs: [surface], + activeSurfaceID: surface, + unseenNotificationCount: 0, + isDormant: true + ) + ) + ) { + $0.isDormant = true + } + // Wake: the flag clears via the same channel. + await store.send( + .projectionChanged( + WorktreeTabProjection( + tabID: tabID, + surfaceIDs: [surface], + activeSurfaceID: surface, + unseenNotificationCount: 0, + isDormant: false + ) + ) + ) { + $0.isDormant = false + } + } + @Test func agentSnapshotChangedShortCircuitsOnEqualArray() async { let tabID = TerminalTabID(rawValue: UUID()) let agents = [ diff --git a/supacodeTests/WorktreeTerminalManagerDormantTests.swift b/supacodeTests/WorktreeTerminalManagerDormantTests.swift new file mode 100644 index 000000000..14c0d4be5 --- /dev/null +++ b/supacodeTests/WorktreeTerminalManagerDormantTests.swift @@ -0,0 +1,1926 @@ +import Clocks +import Dependencies +import DependenciesTestSupport +import Foundation +import GhosttyKit +import Sharing +import SupacodeSettingsShared +import Testing + +@testable import supacode + +/// Shared seeding for the hibernation suites. Pins the flag explicitly in the +/// ambient shared settings the state reads, so dormant coverage never depends on +/// the shipped default. +@MainActor +enum HibernationTestSupport { + static func enableHibernation() { + setHibernation(true) + } + + static func setHibernation(_ enabled: Bool) { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.terminalHibernationEnabled = enabled } + } + + static func setConfirmCloseSurface(_ enabled: Bool) { + @Shared(.settingsFile) var settingsFile + $settingsFile.withLock { $0.global.confirmCloseSurface = enabled } + } +} + +/// Dormant-storage coverage: seeded dormant entries must keep every bookkeeping +/// site (layout capture, surface enumeration, projections, kill lists, +/// quit-confirm signal) truthful about a hibernated tab. +@MainActor +@Suite(.serialized, .dependencies) +struct DormantTerminalTests { + // MARK: - Fixtures + + private func makeWorktree(id: String = "/tmp/repo/wt-dormant") -> Worktree { + Worktree( + id: WorktreeID(id), + name: URL(fileURLWithPath: id).lastPathComponent, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private func makeRemoteWorktree() -> Worktree { + Worktree( + id: WorktreeID("devbox:/tmp/repo/wt-dormant-remote"), + name: "wt-dormant-remote", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-dormant-remote"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo"), + host: RemoteHost(alias: "devbox") + ) + } + + private func makeState() -> WorktreeTerminalState { + HibernationTestSupport.enableHibernation() + return WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + splitPreserveZoomOnNavigation: { false } + ) + } + + private func session(for surfaceID: UUID) -> String { + ZmxSessionID.make(surfaceID: surfaceID) + } + + private func firstSurfaceID(_ state: WorktreeTerminalState, tab: TerminalTabID) -> UUID { + state.splitTree(for: tab).root!.leftmostLeaf().id + } + + /// Manager wired with an in-memory settings file and a zmx client that records + /// every local kill, so quit / prune kill lists can be asserted on-disk. + private struct Harness { + let manager: WorktreeTerminalManager + let killed: LockIsolated<[String]> + let storage: SettingsFileStorage + let url: URL + } + + private func makeHarness() -> Harness { + let killed = LockIsolated<[String]>([]) + let storage = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let manager = withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { nil }, + isBundled: { true }, + killSession: { id in killed.withValue { $0.append(id) } }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + $0.settingsFileStorage = storage + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime()) + } + manager.saveLayoutSnapshot = { _, _ in } + return Harness(manager: manager, killed: killed, storage: storage, url: url) + } + + private func readLayouts(_ harness: Harness) -> [String: TerminalLayoutSnapshot] { + guard let data = try? harness.storage.load(harness.url) else { return [:] } + return (try? JSONDecoder().decode([String: TerminalLayoutSnapshot].self, from: data)) ?? [:] + } + + /// Bounded pump so a detached kill Task can land without `Task.sleep`. + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0..<200 { + if predicate() { return } + await Task.megaYield() + } + } + + // MARK: - State-level accessors + + @Test func captureLayoutSnapshotIncludesDormantTab() { + let state = makeState() + let liveTab = state.createTab(focusing: false)! + let dormantTab = state.createTab(focusing: false)! + let dormantSurface = firstSurfaceID(state, tab: dormantTab) + + state.hibernateTabForTesting(dormantTab) + + let snapshot = state.captureLayoutSnapshot() + let ids = snapshot?.tabs.compactMap(\.id) ?? [] + #expect(ids.contains(liveTab.rawValue)) + #expect(ids.contains(dormantTab.rawValue)) + #expect(snapshot?.allSurfaceIDs.contains(dormantSurface) == true) + // Single-leaf dormant tab freezes focus on its only leaf. + let dormantSnapshot = snapshot?.tabs.first { $0.id == dormantTab.rawValue } + #expect(dormantSnapshot?.focusedLeafIndex == 0) + } + + @Test func allSurfaceIDsIncludesDormantLeaves() { + let state = makeState() + let liveTab = state.createTab(focusing: false)! + let liveSurface = firstSurfaceID(state, tab: liveTab) + let dormantTab = state.createTab(focusing: false)! + let dormantSurface = firstSurfaceID(state, tab: dormantTab) + + state.hibernateTabForTesting(dormantTab) + + let all = Set(state.allSurfaceIDs) + #expect(all.contains(liveSurface)) + #expect(all.contains(dormantSurface)) + } + + @Test func tabIDContainingResolvesDormantSurface() { + let state = makeState() + let dormantTab = state.createTab(focusing: false)! + let dormantSurface = firstSurfaceID(state, tab: dormantTab) + + state.hibernateTabForTesting(dormantTab) + + #expect(state.tabID(containing: dormantSurface) == dormantTab) + } + + @Test func hasAnySurfaceTrueWhenOnlyDormantEntriesRemain() { + let state = makeState() + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + + state.hibernateTabForTesting(tab) + + #expect(state.dormantTabLayouts.count == 1) + #expect(state.hasAnySurface) + } + + @Test func dormantKeysAreSubsetOfTabManagerTabs() { + let state = makeState() + let hibernated = state.createTab(focusing: false)! + let live = state.createTab(focusing: false)! + + state.hibernateTabForTesting(hibernated) + + let tabIDs = Set(state.tabManager.tabs.map(\.id)) + #expect(Set(state.dormantTabLayouts.keys).isSubset(of: tabIDs)) + #expect(state.dormantTabLayouts.keys.contains(hibernated)) + #expect(tabIDs.contains(live)) + } + + @Test func hasUnseenNotificationForDormantTabRoutesThroughFrozenLeaves() { + let state = makeState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTabForTesting(tab) + state.setNotificationsForTesting([ + WorktreeTerminalNotification( + surfaceID: surface, + title: "Unread", + body: "body", + createdAt: .distantPast, + isRead: false + ) + ]) + + #expect(state.hasUnseenNotification(forTabID: tab)) + } + + @Test func hibernateTabIsNoOpWhenIneligible() { + // The default noop zmx client makes the leaf non-`usesZmx`, so hibernation is + // refused and the tab stays live. + let state = makeState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + #expect(!state.canHibernate(tabId: tab)) + state.hibernateTab(tab) + + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.surfaceIDs(inTab: tab) == [surface]) + #expect(state.hasSurfaceAnywhere(surface)) + } + + @Test func fireWithIneligibleLeafReArmsTimer() { + // A non-zmx leaf can't hibernate; re-arming avoids wedging on a later flip. + let state = makeState() + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + #expect(!state.canHibernate(tabId: tab)) + + state.fireHibernationTimerForTesting(tab) + + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + } + + @Test func hibernationFreezesAgentRecordsIntoLayout() { + let state = makeState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + let agentRecord = TerminalLayoutSnapshot.SurfaceAgentRecord(agent: "claude", pids: [42], activity: "busy") + state.hibernationAgentsBySurface = { [surface: [agentRecord]] } + + state.hibernateTabForTesting(tab) + + // The captured snapshot embeds the agent record frozen at hibernate time, so a + // dormant-persisted layout keeps presence + image-paste routing across relaunch. + let records = state.captureLayoutSnapshot()?.allAgentRecords() ?? [] + #expect( + records.contains { entry in + entry.surfaceID == surface + && entry.records.contains { $0.agent == "claude" && $0.pids == [42] && $0.activity == "busy" } + } + ) + } + + @Test func dormantSnapshotRefreshesAgentRecordsFromLiveMap() { + let state = makeState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + let busy = TerminalLayoutSnapshot.SurfaceAgentRecord(agent: "claude", pids: [42], activity: "busy") + state.hibernationAgentsBySurface = { [surface: [busy]] } + + state.hibernateTabForTesting(tab) + + // The session watcher moved presence busy->idle while dormant; a snapshot + // saved now must carry the fresh idle record, not the frozen busy one. + let idle = TerminalLayoutSnapshot.SurfaceAgentRecord(agent: "claude", pids: [42], activity: "idle") + let refreshed = state.captureLayoutSnapshot(agentsBySurface: [surface: [idle]])?.allAgentRecords() ?? [] + #expect( + refreshed.contains { entry in + entry.surfaceID == surface && entry.records.contains { $0.activity == "idle" } + } + ) + + // A nil map (no authoritative source wired) keeps the frozen record unchanged. + let frozen = state.captureLayoutSnapshot(agentsBySurface: nil)?.allAgentRecords() ?? [] + #expect( + frozen.contains { entry in + entry.surfaceID == surface && entry.records.contains { $0.activity == "busy" } + } + ) + } + + @Test func dormantSnapshotClearsAgentRecordsWhenAuthoritativeMapOmitsLeaf() { + let state = makeState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + let busy = TerminalLayoutSnapshot.SurfaceAgentRecord(agent: "claude", pids: [42], activity: "busy") + state.hibernationAgentsBySurface = { [surface: [busy]] } + + state.hibernateTabForTesting(tab) + + // The dormant agent emitted session_end while dark, so the authoritative map + // no longer carries the surface: its frozen records must be cleared, not + // resurrected onto disk. + let records = state.captureLayoutSnapshot(agentsBySurface: [UUID(): []])?.allAgentRecords() ?? [] + #expect(!records.contains { $0.surfaceID == surface }) + } + + @Test func hibernationClearsCachedTabProgress() { + let state = makeState() + let captured = LockIsolated<[TerminalTabID: TerminalTabProgressDisplay?]>([:]) + state.onTabProgressDisplayChanged = { tabId, display in + captured.withValue { $0[tabId] = display } + } + let tab = state.createTab(focusing: false)! + let surface = state.splitTree(for: tab).root!.leftmostLeaf() + + // Drive a running stripe through the live pipeline so the tab caches it. + surface.bridge.state.progressState = GHOSTTY_PROGRESS_STATE_INDETERMINATE + surface.bridge.onProgressReport?(GHOSTTY_PROGRESS_STATE_INDETERMINATE) + #expect((state.currentTabProgressDisplays()[tab] ?? nil) != nil) + + state.hibernateTabForTesting(tab) + + // Teardown emits the now-nil display: dormant OSC progress is ConEmu-dropped, + // so a running stripe must not linger for the whole dormant period. + #expect((state.currentTabProgressDisplays()[tab] ?? nil) == nil) + #expect((captured.value[tab] ?? nil) == nil) + } + + @Test func wakeReDerivesFirstTabContextAfterEarlierTabCloses() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let firstTab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: firstTab) + let laterTab = state.createTab(focusing: false)! + let laterSurface = firstSurfaceID(state, tab: laterTab) + + // Hibernate the later (TAB-context) tab, then close the original first tab so + // the later one becomes first. + state.hibernateTab(laterTab) + state.closeTab(firstTab) + + state.wakeTab(laterTab) + + // Wake re-derives the anchor context from the live order: now-first => WINDOW, + // not the stale TAB context frozen at hibernate. + #expect(state.surfaceContextForTesting(laterSurface) == GHOSTTY_SURFACE_CONTEXT_WINDOW) + } + + @Test func orphanedWakeLeafIDsReturnsUnrebuiltLeaves() { + let rebuilt = UUID() + let orphanA = UUID() + let orphanB = UUID() + + let orphaned = WorktreeTerminalState.orphanedWakeLeafIDs( + expected: [rebuilt, orphanA, orphanB], rebuilt: [rebuilt]) + + // A fully-rebuilt tree strands nothing; a partial rebuild reports the missing + // frozen leaves (whose sessions the wake path then kills). + #expect(orphaned == [orphanA, orphanB]) + #expect( + WorktreeTerminalState.orphanedWakeLeafIDs(expected: [rebuilt], rebuilt: [rebuilt]).isEmpty) + } + + // MARK: - Projection defect fix + + @Test func emitTabProjectionForDormantTabProjectsIsDormantWithoutRemoval() { + let state = makeState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + var removed: [TerminalTabID] = [] + var projections: [WorktreeTabProjection] = [] + state.onTabRemoved = { removed.append($0) } + state.onTabProjectionChanged = { projections.append($0) } + + state.hibernateTabForTesting(tab) + + // A dormant tab is still in `tabManager`: no removal, and the projection + // carries the frozen leaves plus the dormancy flag. + #expect(removed.isEmpty) + let dormantProjection = projections.last { $0.tabID == tab } + #expect(dormantProjection?.isDormant == true) + #expect(dormantProjection?.surfaceIDs == [surface]) + #expect(dormantProjection?.activeSurfaceID == surface) + } + + @Test func allTabsDormantOnlyWhenEveryTabHibernated() { + let state = makeState() + #expect(!state.allTabsDormant) + + let liveTab = state.createTab(focusing: false)! + let dormantTab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: dormantTab) + + // Mixed live + dormant: the row must not read as fully asleep. + state.hibernateTabForTesting(dormantTab) + #expect(!state.allTabsDormant) + + // Every tab hibernated: now the whole worktree is dormant. + state.hibernateTabForTesting(liveTab) + #expect(state.allTabsDormant) + } + + @Test func wakeClearsDormantProjectionFlag() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + + var projections: [WorktreeTabProjection] = [] + state.onTabProjectionChanged = { projections.append($0) } + + state.hibernateTab(tab) + #expect(projections.last { $0.tabID == tab }?.isDormant == true) + + state.wakeTab(tab) + #expect(projections.last { $0.tabID == tab }?.isDormant == false) + } + + // MARK: - Manager kill lists + + @Test func terminateAllSessionsKillsDormantSessions() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + let tab = state.createTab(focusing: false)! + let surface = state.splitTree(for: tab).root!.leftmostLeaf().id + + state.hibernateTabForTesting(tab) + await harness.manager.terminateAllSessions() + + #expect(harness.killed.value.contains(session(for: surface))) + } + + @Test func pruneKillsDormantSessions() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + let tab = state.createTab(focusing: false)! + let surface = state.splitTree(for: tab).root!.leftmostLeaf().id + + state.hibernateTabForTesting(tab) + harness.manager.prune(keeping: []) + await waitUntil { harness.killed.value.contains(session(for: surface)) } + + #expect(harness.killed.value.contains(session(for: surface))) + } + + @Test func saveAllLayoutSnapshotsPersistsDormantSurfaceIDs() { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + let tab = state.createTab(focusing: false)! + let surface = state.splitTree(for: tab).root!.leftmostLeaf().id + + state.hibernateTabForTesting(tab) + harness.manager.saveAllLayoutSnapshots() + + let persisted = readLayouts(harness)[worktree.id.rawValue] + #expect(persisted?.allSurfaceIDs.contains(surface) == true) + } + + // MARK: - Manager wiring + + @Test func hibernatingEveryTabEmitsAllTabsDormantProjection() async { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + let tab = state.createTab(focusing: false)! + _ = state.splitTree(for: tab).root!.leftmostLeaf().id + + let projections = LockIsolated<[WorktreeRowProjection]>([]) + let stream = harness.manager.eventStream() + let collector = Task { + for await event in stream { + if case .worktreeProjectionChanged(let id, let projection) = event, id == worktree.id { + projections.withValue { $0.append(projection) } + } + } + } + + state.hibernateTabForTesting(tab) + await waitUntil { projections.value.contains { $0.allTabsDormant } } + collector.cancel() + + // The manager forwards the row projection so the sidebar sleep marker tracks + // `allTabsDormant`; deleting that wiring would fail this. + #expect(projections.value.contains { $0.allTabsDormant }) + } + + @Test func hibernationCancelsPendingIdleHooksWithoutDroppingPresence() { + let harness = makeHarness() + let worktree = makeWorktree() + let state = harness.manager.state(for: worktree) + let tab = state.createTab(focusing: false)! + let surface = state.splitTree(for: tab).root!.leftmostLeaf().id + let closed = LockIsolated>([]) + state.onSurfacesClosed = { ids in closed.withValue { $0.formUnion(ids) } } + + // An OSC 3008 idle presence signal seeds a pending idle-debounce task. + state.deliverDormantOSCForTesting( + surfaceID: surface, + sequence: ZmxOSCSequence(code: 3008, payload: Array("start=claude;event=idle".utf8)) + ) + #expect(harness.manager.pendingIdleHookCountForTesting == 1) + + state.hibernateTabForTesting(tab) + + // `onSurfacesHibernated` cancels the idle hook but never kills the session or + // fires `onSurfacesClosed` (presence survives hibernation). + #expect(harness.manager.pendingIdleHookCountForTesting == 0) + #expect(harness.killed.value.isEmpty) + #expect(closed.value.isEmpty) + } + + @Test func pruneKillsDormantRemoteHostSessions() async { + let killedRemote = LockIsolated<[String]>([]) + let manager = withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { _ in }, + killRemoteSession: { _, id in killedRemote.withValue { $0.append(id) } }, + listSessionsWithClients: { [] } + ) + $0.settingsFileStorage = .inMemory() + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime()) + } + manager.saveLayoutSnapshot = { _, _ in } + let worktree = makeRemoteWorktree() + let state = manager.state(for: worktree) + let tab = state.createTab(focusing: false)! + let surface = state.splitTree(for: tab).root!.leftmostLeaf().id + state.hibernateTabForTesting(tab) + + manager.prune(keeping: []) + await waitUntil { killedRemote.value.contains(session(for: surface)) } + + // A dormant remote leaf's host-side session is torn down over SSH on prune. + #expect(killedRemote.value.contains(session(for: surface))) + } + + // MARK: - Real hibernate / wake + + /// State bound to a zmx client that reports an executable (so every surface is + /// `usesZmx` and thus hibernation-eligible) and records each local kill. + private func makeZmxState( + killed: LockIsolated<[String]> = LockIsolated([]), + surfaceNeedsCloseConfirmation: @escaping (GhosttySurfaceView) -> Bool = { _ in false } + ) -> WorktreeTerminalState { + HibernationTestSupport.enableHibernation() + return withDependencies { + $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 0) + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { id in killed.withValue { $0.append(id) } }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + } operation: { + WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + splitPreserveZoomOnNavigation: { false }, + surfaceNeedsCloseConfirmation: surfaceNeedsCloseConfirmation + ) + } + } + + private func layout(_ state: WorktreeTerminalState, tab: TerminalTabID) + -> TerminalLayoutSnapshot.LayoutNode? + { + state.captureLayoutSnapshot()?.tabs.first { $0.id == tab.rawValue }?.layout + } + + /// Preorder split ratios of a layout subtree, so a shape/ratio comparison + /// survives hibernate/wake without depending on pwd fields. + private func splitRatios(_ node: TerminalLayoutSnapshot.LayoutNode) -> [Double] { + switch node { + case .leaf: + return [] + case .split(let split): + return [split.ratio] + splitRatios(split.left) + splitRatios(split.right) + } + } + + @Test func hibernateWakeCycleKeepsSurfaceIDsAndSkipsKill() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let originalID = firstSurfaceID(state, tab: tab) + + for _ in 0..<3 { + #expect(state.canHibernate(tabId: tab)) + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] != nil) + // Dormant-aware queries still resolve the frozen leaf so validation accepts it. + #expect(state.surfaceIDs(inTab: tab) == [originalID]) + #expect(state.hasSurfaceAnywhere(originalID)) + // The live surface is torn down, but its per-surface state is preserved + // through hibernation (holding the unseen counter, here zero). + #expect(state.surfaceStates[originalID]?.unseenNotificationCount == 0) + + state.wakeTab(tab) + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.surfaceIDs(inTab: tab) == [originalID]) + #expect(state.hasSurfaceAnywhere(originalID)) + #expect(state.surfaceStates[originalID] != nil) + } + // Reattach never kills: the zmx sessions must outlive every cycle. + #expect(killed.value.isEmpty) + } + + @Test func focusSurfaceWakesDormantTabAndSelectsIt() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let dormantTab = state.createTab(focusing: false)! + let dormantID = firstSurfaceID(state, tab: dormantTab) + let otherTab = state.createTab(focusing: true)! + _ = firstSurfaceID(state, tab: otherTab) + + state.hibernateTab(dormantTab) + #expect(state.dormantTabLayouts[dormantTab] != nil) + + #expect(state.focusSurface(id: dormantID)) + #expect(state.dormantTabLayouts[dormantTab] == nil) + #expect(state.tabManager.selectedTabId == dormantTab) + #expect(state.surfaceIDs(inTab: dormantTab) == [dormantID]) + #expect(killed.value.isEmpty) + } + + @Test func closingDormantTabAlwaysConfirms() async { + HibernationTestSupport.setConfirmCloseSurface(true) + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let surfaceID = firstSurfaceID(state, tab: tab) + // Control: no live leaf reports a running process, so dormancy is the only + // thing that can raise the prompt below. + let idleTab = state.createTab(focusing: true)! + _ = firstSurfaceID(state, tab: idleTab) + #expect(state.requestCloseTab(idleTab)) + #expect(state.pendingCloseConfirmation == nil) + + state.hibernateTab(tab) + #expect(state.requestCloseTab(tab)) + #expect(state.pendingCloseConfirmation == .tabs([tab], reason: .dormant)) + #expect(state.hasTab(tab)) + + state.confirmPendingClose() + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tab)) + // Confirming must run the full dormant teardown, not just drop the row. + await waitUntil { killed.value.contains(session(for: surfaceID)) } + #expect(killed.value.contains(session(for: surfaceID))) + #expect(state.watchedDormantSurfaceIDsForTesting.isEmpty) + #expect(state.surfaceStates[surfaceID] == nil) + } + + @Test func closeAllTabsConfirmsOnceAndDrainsLiveAndDormantTabs() async { + HibernationTestSupport.setConfirmCloseSurface(true) + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let liveTab = state.createTab(focusing: true)! + let liveSurface = firstSurfaceID(state, tab: liveTab) + let dormantTab = state.createTab(focusing: false)! + let dormantSurface = firstSurfaceID(state, tab: dormantTab) + state.hibernateTab(dormantTab) + + #expect(state.requestCloseAllTabs()) + // The live tab is idle, so dormancy alone raised the prompt. + #expect(state.pendingCloseConfirmation == .tabs([liveTab, dormantTab], reason: .dormant)) + + state.confirmPendingClose() + await waitUntil { killed.value.contains(session(for: dormantSurface)) } + #expect(!state.hasTab(liveTab)) + #expect(!state.hasTab(dormantTab)) + #expect(state.dormantTabLayouts.isEmpty) + #expect(!state.hasSurfaceAnywhere(liveSurface)) + } + + @Test func liveRunningTabInBatchKeepsTheRunningProcessCopy() { + HibernationTestSupport.setConfirmCloseSurface(true) + let running = LockIsolated>([]) + let state = makeZmxState(surfaceNeedsCloseConfirmation: { view in running.value.contains(view.id) }) + let liveTab = state.createTab(focusing: true)! + let liveSurface = firstSurfaceID(state, tab: liveTab) + running.withValue { $0.insert(liveSurface) } + let dormantTab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: dormantTab) + state.hibernateTab(dormantTab) + + // A live leaf really is running, so that outranks the dormant tab. + #expect(state.requestCloseAllTabs()) + #expect(state.pendingCloseConfirmation == .tabs([liveTab, dormantTab], reason: .runningProcess)) + + // The reason rides on the payload: the process reaching its prompt while the + // alert is up must not flip the copy to the dormant wording. + running.withValue { $0.removeAll() } + #expect( + state.pendingCloseConfirmation?.message == WorktreeTerminalState.CloseConfirmationReason.runningProcess.message) + } + + @Test func wokenTabNoLongerForcesConfirmation() { + HibernationTestSupport.setConfirmCloseSurface(true) + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + state.wakeTab(tab) + + // Dormancy was the only reason this tab confirmed; awake and idle, it must not. + #expect(state.requestCloseTab(tab)) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tab)) + } + + @Test func tabWithPendingCloseConfirmationDoesNotHibernate() { + HibernationTestSupport.setConfirmCloseSurface(true) + let state = makeZmxState(surfaceNeedsCloseConfirmation: { _ in true }) + let tab = state.createTab(focusing: true)! + let surfaceID = firstSurfaceID(state, tab: tab) + + #expect(state.requestCloseTab(tab)) + #expect(state.pendingCloseConfirmation == .tabs([tab], reason: .runningProcess)) + + // Hibernating here would tear the alert's target down and drop the request. + #expect(!state.canHibernate(tabId: tab)) + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.pendingCloseConfirmation == .tabs([tab], reason: .runningProcess)) + + state.confirmPendingClose() + #expect(!state.hasTab(tab)) + #expect(!state.hasSurfaceAnywhere(surfaceID)) + } + + @Test func closingDormantTabSkipsConfirmationWhenSettingIsOff() { + HibernationTestSupport.setConfirmCloseSurface(false) + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + #expect(state.requestCloseTab(tab)) + #expect(state.pendingCloseConfirmation == nil) + #expect(!state.hasTab(tab)) + } + + @Test func hibernateWakeRestoresSplitShapeAndZoom() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let firstID = firstSurfaceID(state, tab: tab) + #expect(state.performSplitAction(.newSplit(direction: .right), for: firstID)) + let idsBefore = state.surfaceIDs(inTab: tab) + #expect(idsBefore.count == 2) + #expect(state.performSplitAction(.toggleSplitZoom, for: idsBefore[1])) + #expect(state.isSplitZoomed(forTabID: tab)) + let layoutBefore = layout(state, tab: tab) + + state.hibernateTab(tab) + state.wakeTab(tab) + + #expect(state.surfaceIDs(inTab: tab) == idsBefore) + #expect(state.isSplitZoomed(forTabID: tab)) + let layoutAfter = layout(state, tab: tab) + #expect(layoutBefore.map(splitRatios) == layoutAfter.map(splitRatios)) + #expect(layoutBefore?.leafSurfaceIDs == layoutAfter?.leafSurfaceIDs) + #expect(killed.value.isEmpty) + } + + @Test func multiLeafDormantTabRoundTripsThroughSnapshotCodable() throws { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let firstID = firstSurfaceID(state, tab: tab) + #expect(state.performSplitAction(.newSplit(direction: .right), for: firstID)) + #expect(state.renameTab(tab, title: "Custom Name")) + let idsBefore = state.surfaceIDs(inTab: tab) + #expect(idsBefore.count == 2) + + state.hibernateTab(tab) + let snapshot = state.captureLayoutSnapshot()! + let originalLayout = snapshot.tabs.first { $0.id == tab.rawValue }?.layout + + // The persisted boundary is JSON: round-trip through the Codable snapshot. + let data = try JSONEncoder().encode(snapshot) + let decoded = try JSONDecoder().decode(TerminalLayoutSnapshot.self, from: data) + + // Restore into a fresh state and compare shape / title / leaf ids. + let restored = makeZmxState(killed: killed) + restored.pendingLayoutSnapshot = decoded + restored.ensureInitialTab(focusing: false) + + let restoredTab = restored.tabManager.tabs.first { $0.id == tab } + #expect(restoredTab?.customTitle == "Custom Name") + let restoredLayout = layout(restored, tab: tab) + #expect(restoredLayout.map(splitRatios) == originalLayout.map(splitRatios)) + #expect(restoredLayout?.leafSurfaceIDs == idsBefore) + } + + @Test func staleRenderOfClosedDormantTabMintsNothing() async { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + state.closeTab(tab) + await waitUntil { killed.value.contains(session(for: surface)) } + + // The closed tab is gone; a stale render must find nothing and mint nothing. + #expect(state.splitTree(for: tab).isEmpty) + #expect(!state.hasSurfaceAnywhere(surface)) + #expect(state.dormantTabLayouts[tab] == nil) + } + + @Test func midHibernationCloseRequestIsInert() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let view = state.splitTree(for: tab).root!.leftmostLeaf() + let surface = view.id + + state.hibernateTab(tab) + // The live surface is torn down, but its per-surface state is preserved + // through hibernation (counter zero), and the dormant-aware queries still + // know the frozen leaf. + #expect(state.surfaceStates[surface]?.unseenNotificationCount == 0) + #expect(state.hasSurfaceAnywhere(surface)) + + // A late close callback from the torn-down view hits the `surfaces[id] === + // view` guard: nothing is killed, replaced, or resurrected. + view.bridge.onCloseRequest?(false) + + #expect(killed.value.isEmpty) + #expect(state.surfaceStates[surface]?.unseenNotificationCount == 0) + #expect(state.dormantTabLayouts[tab] != nil) + } + + @Test func unseenDotsSurviveHibernateAndWake() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + state.appendHookNotification(title: "Done", body: "body", surfaceID: surface) + #expect(state.hasUnseenNotification) + #expect(state.surfaceStates[surface]?.hasUnseenNotification == true) + + state.hibernateTab(tab) + // Hibernation preserves the per-surface state so its unseen counter (which + // the worktree dot and per-tab count read) survives the dark period. + #expect(state.hasUnseenNotification) + #expect(state.hasUnseenNotification(forTabID: tab)) + #expect(state.surfaceStates[surface]?.hasUnseenNotification == true) + + state.wakeTab(tab) + // The preserved counter is re-adopted under the original UUID; wake neither + // re-derives nor clears it. + #expect(state.surfaceStates[surface]?.hasUnseenNotification == true) + #expect(state.hasUnseenNotification(forTabID: tab)) + } + + @Test func closingDormantTabKillsSessionsAndPurges() async { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + var closed: Set = [] + var removed: [TerminalTabID] = [] + state.onSurfacesClosed = { closed.formUnion($0) } + state.onTabRemoved = { removed.append($0) } + + state.hibernateTab(tab) + #expect(state.watchedDormantSurfaceIDsForTesting == [surface]) + state.closeTab(tab) + await waitUntil { killed.value.contains(session(for: surface)) } + + #expect(killed.value.contains(session(for: surface))) + #expect(closed.contains(surface)) + #expect(removed.contains(tab)) + #expect(state.dormantTabLayouts[tab] == nil) + #expect(!state.hasTab(tab)) + // The `dormantTabLayouts` didSet stopped the watcher (before the kill above). + #expect(state.watchedDormantSurfaceIDsForTesting.isEmpty) + } + + @Test func closeAllSurfacesDrainsDormantAndDropsPresence() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let liveTab = state.createTab(focusing: false)! + let liveSurface = firstSurfaceID(state, tab: liveTab) + let dormantTab = state.createTab(focusing: false)! + let dormantSurface = firstSurfaceID(state, tab: dormantTab) + var closed: Set = [] + state.onSurfacesClosed = { closed.formUnion($0) } + + state.hibernateTab(dormantTab) + state.closeAllSurfaces() + + #expect(closed.contains(liveSurface)) + #expect(closed.contains(dormantSurface)) + #expect(state.dormantTabLayouts.isEmpty) + // `closeAllSurfaces` never kills; the quit / prune callers do, off the + // dormant-inclusive `allSurfaceIDs` snapshot. + #expect(killed.value.isEmpty) + } + + @Test func canHibernateTrueForZmxTab() { + let killed = LockIsolated<[String]>([]) + let state = makeZmxState(killed: killed) + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + #expect(state.canHibernate(tabId: tab)) + } + + @Test func canHibernateFalseForNonZmxLeaf() { + // The default noop zmx client reports no executable, so the surface is not + // `usesZmx` and tearing it down would kill an unrecoverable shell. + let state = makeState() + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + #expect(!state.canHibernate(tabId: tab)) + } + + @Test func canHibernateFalseForBlockingScriptTab() { + let harness = makeHarness() + let worktree = makeWorktree() + harness.manager.handleCommand(.runBlockingScript(worktree, kind: .archive, script: "echo ok")) + guard let state = harness.manager.stateIfExists(for: worktree.id), + let tab = state.tabManager.selectedTabId + else { + Issue.record("Expected a blocking-script tab") + return + } + #expect(!state.canHibernate(tabId: tab)) + } + + // MARK: - CLI destroy into a dormant tab + + /// Manager plus a terminal state for `worktree`, both built inside one + /// dependency scope so the state's close path kills through the recording zmx + /// client (a state built later would capture the ambient default and record no + /// kills). + private func makeManagerAndState( + for worktree: Worktree + ) -> (harness: Harness, state: WorktreeTerminalState) { + let killed = LockIsolated<[String]>([]) + let storage = SettingsFileStorage.inMemory() + let url = SupacodePaths.layoutsURL + let (manager, state) = withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { nil }, + isBundled: { true }, + killSession: { id in killed.withValue { $0.append(id) } }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + $0.settingsFileStorage = storage + } operation: { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + manager.saveLayoutSnapshot = { _, _ in } + return (manager, manager.state(for: worktree)) + } + return (Harness(manager: manager, killed: killed, storage: storage, url: url), state) + } + + /// Chains a capture over the manager's own `onSurfacesClosed` sink so the test + /// observes the presence drop the CLI ack keys on without unwiring the manager. + private func captureSurfacesClosed( + _ state: WorktreeTerminalState, + into sink: LockIsolated> + ) { + let managerSink = state.onSurfacesClosed + state.onSurfacesClosed = { ids in + sink.withValue { $0.formUnion(ids) } + managerSink?(ids) + } + } + + @Test func destroySurfaceWakesDormantTabAndClosesFrozenLeaf() async { + let worktree = makeWorktree() + let (harness, state) = makeManagerAndState(for: worktree) + let closed = LockIsolated>([]) + captureSurfacesClosed(state, into: closed) + + let tab = state.createTab(focusing: false)! + let firstID = firstSurfaceID(state, tab: tab) + #expect(state.performSplitAction(.newSplit(direction: .right), for: firstID)) + let idsBefore = state.surfaceIDs(inTab: tab) + #expect(idsBefore.count == 2) + let target = idsBefore[0] + let survivor = idsBefore[1] + + // Hibernate a background (deselected) tab, then drive the CLI destroy at a + // frozen leaf: the tab must wake so the close reaches a live surface. + state.hibernateTabForTesting(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + harness.manager.handleCommand(.destroySurface(worktree, tabID: tab, surfaceID: target)) + + // The tab woke, so the target is a live surface again. The explicit-close + // binding tears it down; when ghostty defers its close callback, drive it the + // way ghostty would so the teardown completes. + if let targetView = state.splitTree(for: tab).leaves().first(where: { $0.id == target }) { + targetView.bridge.closeSurface(processAlive: false) + } + await waitUntil { harness.killed.value.contains(session(for: target)) } + + // The command completed: the frozen leaf's presence dropped and its session died. + #expect(closed.value.contains(target)) + #expect(harness.killed.value.contains(session(for: target))) + // The remaining pane survived the implicit wake with its original UUID. + #expect(state.surfaceIDs(inTab: tab) == [survivor]) + #expect(state.hasSurfaceAnywhere(survivor)) + #expect(!state.hasSurfaceAnywhere(target)) + #expect(state.hasTab(tab)) + #expect(state.dormantTabLayouts[tab] == nil) + // No duplicate teardown: the target session died exactly once, the survivor never. + #expect(harness.killed.value.filter { $0 == session(for: target) }.count == 1) + #expect(!harness.killed.value.contains(session(for: survivor))) + } + + @Test func destroySurfaceWakesDormantTabAndClosesLastLeaf() async { + let worktree = makeWorktree() + let (harness, state) = makeManagerAndState(for: worktree) + let closed = LockIsolated>([]) + captureSurfacesClosed(state, into: closed) + + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTabForTesting(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + harness.manager.handleCommand(.destroySurface(worktree, tabID: tab, surfaceID: surface)) + + // The tab woke; drive ghostty's close callback when it is deferred so the + // last leaf's teardown completes. + if let view = state.splitTree(for: tab).leaves().first(where: { $0.id == surface }) { + view.bridge.closeSurface(processAlive: false) + } + await waitUntil { harness.killed.value.contains(session(for: surface)) } + + // Destroying the only leaf collapses the tab, matching the live close path. + #expect(closed.value.contains(surface)) + #expect(harness.killed.value.contains(session(for: surface))) + #expect(!state.hasTab(tab)) + #expect(!state.hasSurfaceAnywhere(surface)) + #expect(state.dormantTabLayouts[tab] == nil) + } +} + +/// Visibility tracking and grace timers. A TestClock drives every grace window. +@MainActor +@Suite(.serialized, .dependencies) +struct HibernationTimerTests { + private func makeWorktree() -> Worktree { + Worktree( + id: WorktreeID("/tmp/repo/wt-hib-timer"), + name: "wt-hib-timer", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-hib-timer"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private struct Harness { + let manager: WorktreeTerminalManager + let clock: TestClock + let killed: LockIsolated<[String]> + let agents: LockIsolated<[UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]]> + let worktree: Worktree + } + + private func makeHarness() -> Harness { + let clock = TestClock() + let killed = LockIsolated<[String]>([]) + let agents = LockIsolated<[UUID: [TerminalLayoutSnapshot.SurfaceAgentRecord]]>([:]) + let manager = withDependencies { + $0.zmxClient = Self.zmxClient(killed: killed) + $0.settingsFileStorage = .inMemory() + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime(), clock: clock) + } + manager.saveLayoutSnapshot = { _, _ in } + manager.currentAgentsBySurface = { agents.value } + return Harness(manager: manager, clock: clock, killed: killed, agents: agents, worktree: makeWorktree()) + } + + private static func zmxClient(killed: LockIsolated<[String]>) -> ZmxClient { + ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { id in killed.withValue { $0.append(id) } }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + } + + /// Creates the worktree state inside the harness dependency scope so its zmx + /// surfaces are `usesZmx` (hibernation-eligible) and its kills are recorded. + private func registerState(_ harness: Harness) -> WorktreeTerminalState { + HibernationTestSupport.enableHibernation() + return withDependencies { + $0.zmxClient = Self.zmxClient(killed: harness.killed) + $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 0) + } operation: { + harness.manager.state(for: harness.worktree) + } + } + + private func firstSurfaceID(_ state: WorktreeTerminalState, tab: TerminalTabID) -> UUID { + state.splitTree(for: tab).root!.leftmostLeaf().id + } + + private nonisolated func record(_ activity: String, pids: [Int32]) -> TerminalLayoutSnapshot.SurfaceAgentRecord { + .init(agent: "claude", pids: pids, activity: activity) + } + + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0..<400 { + if predicate() { return } + await Task.megaYield() + } + } + + private func pump() async { + for _ in 0..<40 { await Task.megaYield() } + } + + /// Lets a just-scheduled (or re-armed) grace-timer Task register its sleep with + /// the TestClock before advancing, then lets the fired continuation run. The + /// bounded settle mirrors the presence / bridge de-flake: a single megaYield + /// can advance past a re-armed sleep that hasn't registered yet, so a + /// defer-then-re-arm gate flakes; `count: 1000` closes that window. + private func advance(_ harness: Harness, by duration: Duration) async { + await Task.megaYield(count: 1000) + await harness.clock.advance(by: duration) + await pump() + } + + // MARK: - Visibility + + @Test func hiddenTabHibernatesAfterGraceWindow() async { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + + await advance(harness, by: .seconds(5 * 60)) + await waitUntil { state.dormantTabLayouts[tab] != nil } + + #expect(state.dormantTabLayouts[tab] != nil) + #expect(!state.scheduledHibernationTabsForTesting.contains(tab)) + } + + @Test func selectingTabCancelsItsTimer() { + let harness = makeHarness() + let state = registerState(harness) + let tabA = state.createTab(focusing: false)! + let tabB = state.createTab(focusing: false)! + state.setWorktreeSelected(true) + // B is the selected tab, so only A stays scheduled. + #expect(state.scheduledHibernationTabsForTesting == [tabA]) + + state.selectTab(tabA) + // A is now visible (cancelled); B became hidden (scheduled). + #expect(state.scheduledHibernationTabsForTesting == [tabB]) + } + + @Test func selectingWorktreeCancelsOnlySelectedTabTimer() { + let harness = makeHarness() + let state = registerState(harness) + let tabA = state.createTab(focusing: false)! + let tabB = state.createTab(focusing: false)! + // Deselected worktree: both scheduled. + #expect(state.scheduledHibernationTabsForTesting == [tabA, tabB]) + + state.setWorktreeSelected(true) + // Only the selected tab's timer cancels; the other stays. + #expect(state.scheduledHibernationTabsForTesting == [tabA]) + } + + @Test func deselectingWorktreeSchedulesAllTabs() { + let harness = makeHarness() + let state = registerState(harness) + let tabA = state.createTab(focusing: false)! + let tabB = state.createTab(focusing: false)! + state.setWorktreeSelected(true) + #expect(state.scheduledHibernationTabsForTesting == [tabA]) + + state.setWorktreeSelected(false) + #expect(state.scheduledHibernationTabsForTesting == [tabA, tabB]) + } + + @Test func managerSelectionInputDrivesVisibility() { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + + harness.manager.handleCommand(.setSelectedWorktreeID(harness.worktree.id)) + #expect(!state.scheduledHibernationTabsForTesting.contains(tab)) + + harness.manager.handleCommand(.setSelectedWorktreeID(nil)) + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + } + + // MARK: - Working agents + + @Test func workingAgentTabHibernatesOnScheduleFreezingRecords() async { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + // An actively-working agent must not block hibernation: the zmx session keeps + // the process alive and the dormant watcher keeps notifications lossless. + harness.agents.setValue([surface: [record("busy", pids: [123])]]) + + await advance(harness, by: .seconds(5 * 60)) + await waitUntil { state.dormantTabLayouts[tab] != nil } + #expect(state.dormantTabLayouts[tab] != nil) + #expect(!state.scheduledHibernationTabsForTesting.contains(tab)) + + // The busy record freezes into the dormant layout so presence + image-paste + // routing survive a snapshot persisted while dark. + let records = state.captureLayoutSnapshot()?.allAgentRecords() ?? [] + #expect( + records.contains { entry in + entry.surfaceID == surface + && entry.records.contains { $0.agent == "claude" && $0.pids == [123] && $0.activity == "busy" } + } + ) + } + + // MARK: - Fire-time backstops + + @Test func closedTabFireIsInert() async { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.closeTab(tab) + await waitUntil { harness.killed.value.contains(ZmxSessionID.make(surfaceID: surface)) } + + // A late fire for a gone tab hibernates nothing and does not resurrect it. + state.fireHibernationTimerForTesting(tab) + #expect(state.dormantTabLayouts[tab] == nil) + #expect(!state.hasTab(tab)) + } + + @Test func visibleTabFireDoesNotHibernate() { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + // The tab is now the selected, visible tab. + state.setWorktreeSelected(true) + + // A stale fire re-checks visibility and refuses to hibernate. + state.fireHibernationTimerForTesting(tab) + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.surfaceIDs(inTab: tab).count == 1) + } +} + +/// CLI / deeplink paths wake a dormant tab before mutating its surfaces, +/// unread-jump reaches a dormant tab, and a CLI `tab new` consumes a staged +/// layout before adding its tab. +@MainActor +@Suite(.serialized, .dependencies) +struct DormantCLIWakeTests { + private func makeWorktree() -> Worktree { + Worktree( + id: WorktreeID("/tmp/repo/wt-hib-cli"), + name: "wt-hib-cli", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-hib-cli"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private struct Harness { + let manager: WorktreeTerminalManager + let killed: LockIsolated<[String]> + let worktree: Worktree + } + + private func makeHarness() -> Harness { + let killed = LockIsolated<[String]>([]) + let manager = withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { id in killed.withValue { $0.append(id) } }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + $0.settingsFileStorage = .inMemory() + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime(), clock: TestClock()) + } + manager.saveLayoutSnapshot = { _, _ in } + return Harness(manager: manager, killed: killed, worktree: makeWorktree()) + } + + private func registerState(_ harness: Harness) -> WorktreeTerminalState { + HibernationTestSupport.enableHibernation() + return withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { id in harness.killed.withValue { $0.append(id) } }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 0) + } operation: { + harness.manager.state(for: harness.worktree) + } + } + + private func firstSurfaceID(_ state: WorktreeTerminalState, tab: TerminalTabID) -> UUID { + state.splitTree(for: tab).root!.leftmostLeaf().id + } + + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0..<400 { + if predicate() { return } + await Task.megaYield() + } + } + + @Test func splitSurfaceOnDormantTabWakesAndReArms() { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + harness.manager.handleCommand( + .splitSurface( + harness.worktree, tabID: tab, surfaceID: surface, direction: .vertical, input: nil, id: UUID() + ) + ) + + // Woke with the SAME anchor UUID (no mint), added the split, re-armed the timer. + #expect(state.dormantTabLayouts[tab] == nil) + let ids = state.surfaceIDs(inTab: tab) + #expect(ids.contains(surface)) + #expect(ids.count == 2) + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + #expect(harness.killed.value.isEmpty) + } + + @Test func focusSurfaceOnDormantTabWakesToSameSurface() { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + harness.manager.handleCommand( + .focusSurface(harness.worktree, tabID: tab, surfaceID: surface, input: nil) + ) + + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.surfaceIDs(inTab: tab) == [surface]) + #expect(harness.killed.value.isEmpty) + } + + @Test func jumpToUnreadReachesDormantTab() { + let harness = makeHarness() + let state = registerState(harness) + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + state.appendHookNotification(title: "Done", body: "body", surfaceID: surface) + + state.hibernateTab(tab) + + // The dormant-aware lookup still resolves the notification's tab. + let location = harness.manager.latestUnreadNotificationLocation() + #expect(location?.tabID == tab) + #expect(location?.surfaceID == surface) + + // The jump's focus command wakes it onto the same surface. + harness.manager.handleCommand( + .focusSurface(harness.worktree, tabID: tab, surfaceID: surface, input: nil) + ) + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.surfaceIDs(inTab: tab).contains(surface)) + } + + @Test func cliCreateTabConsumesStagedSnapshotFirst() async { + let harness = makeHarness() + let snapTabID = UUID() + let snapSurfaceID = UUID() + let snapshot = TerminalLayoutSnapshot( + tabs: [ + .init( + id: snapTabID, + title: "Restored", + customTitle: nil, + icon: nil, + tintColor: nil, + layout: .leaf(.init(id: snapSurfaceID, workingDirectory: nil)), + focusedLeafIndex: 0 + ) + ], + selectedTabIndex: 0 + ) + harness.manager.loadLayoutSnapshot = { $0 == harness.worktree.id ? snapshot : nil } + + let newTabID = UUID() + harness.manager.handleCommand( + .createTab(harness.worktree, runSetupScriptIfNew: false, id: newTabID, title: nil) + ) + + await waitUntil { (harness.manager.stateIfExists(for: harness.worktree.id)?.tabManager.tabs.count ?? 0) >= 2 } + guard let state = harness.manager.stateIfExists(for: harness.worktree.id) else { + Issue.record("Expected a worktree state") + return + } + let ids = Set(state.tabManager.tabs.map { $0.id.rawValue }) + // The persisted tab survived, and the CLI tab was added on top of it. + #expect(ids.contains(snapTabID)) + #expect(ids.contains(newTabID)) + #expect(state.hasAttemptedInitialTab) + } +} + +/// OSC signals recovered off a dormant session's socket route into the same +/// notification / presence / title handlers a live surface uses, and the wake / +/// close overlap is tolerated (accept live-or-dormant, drop unknown). +@MainActor +@Suite(.serialized, .dependencies) +struct DormantOSCIngestTests { + private func makeWorktree() -> Worktree { + Worktree( + id: WorktreeID("/tmp/repo/wt-hib-osc"), + name: "wt-hib-osc", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-hib-osc"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + /// State bound to a zmx client that reports an executable (so every surface is + /// `usesZmx` and hibernation-eligible) on an immediate clock, so the OSC 9 + /// hold-window Task resolves within a pump. + private func makeZmxState() -> WorktreeTerminalState { + HibernationTestSupport.enableHibernation() + return withDependencies { + $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 0) + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { _ in }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + } operation: { + WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + splitPreserveZoomOnNavigation: { false } + ) + } + } + + private func firstSurfaceID(_ state: WorktreeTerminalState, tab: TerminalTabID) -> UUID { + state.splitTree(for: tab).root!.leftmostLeaf().id + } + + private func osc(_ code: Int, _ payload: String) -> ZmxOSCSequence { + ZmxOSCSequence(code: code, payload: Array(payload.utf8)) + } + + private func pump() async { + for _ in 0..<40 { await Task.megaYield() } + } + + private func waitUntil(_ predicate: () -> Bool) async { + for _ in 0..<200 { + if predicate() { return } + await Task.megaYield() + } + } + + // MARK: - OSC 9 notifications + + @Test func osc9ForDormantLeafRecordsNotificationAndFlipsDot() async { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(9, "Build finished")) + await waitUntil { state.hasUnseenNotification } + + #expect(state.hasUnseenNotification) + #expect(state.hasUnseenNotification(forTabID: tab)) + #expect(state.unreadNotifications().contains { $0.surfaceID == surface && $0.body == "Build finished" }) + // No live surface was minted: the tab is still dormant. The frozen leaf's + // preserved surface state carried the OSC-bumped unseen counter, and the + // dormant-aware query still resolves the frozen leaf. + #expect(state.dormantTabLayouts[tab] != nil) + #expect(state.surfaceStates[surface]?.hasUnseenNotification == true) + #expect(state.surfaceIDs(inTab: tab) == [surface]) + } + + @Test func osc9DormantLeafIncrementsTabUnseenCount() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + // Two custom (hook) notifications on the dormant leaf bump the preserved + // surface state's counter, so the tab's unseen count reflects both under the + // counter model (the capped log alone could undercount). + state.appendHookNotification(title: "One", body: "first", surfaceID: surface) + state.appendHookNotification(title: "Two", body: "second", surfaceID: surface) + + #expect(state.unseenNotificationCount(forTabID: tab) == 2) + #expect(state.hasUnseenNotification(forTabID: tab)) + #expect(state.surfaceStates[surface]?.unseenNotificationCount == 2) + } + + @Test func closingDormantTabWithUnreadClearsPreservedStateAndIndicator() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + // A hook notification on the frozen leaf bumps its preserved unseen counter. + state.appendHookNotification(title: "Done", body: "body", surfaceID: surface) + #expect(state.hasUnseenNotification) + + var indicatorFires = 0 + state.onNotificationIndicatorChanged = { indicatorFires += 1 } + state.closeTab(tab) + + // Closing the dormant tab drops the state hibernation preserved for its unseen + // counter and refreshes the indicator, so the worktree dot / total can't strand. + #expect(!state.hasUnseenNotification) + #expect(state.surfaceStates[surface] == nil) + #expect(indicatorFires == 1) + } + + @Test func closeAllSurfacesWithDormantUnreadClearsPreservedStateAndIndicator() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + state.appendHookNotification(title: "Done", body: "body", surfaceID: surface) + #expect(state.hasUnseenNotification) + + var indicatorFires = 0 + state.onNotificationIndicatorChanged = { indicatorFires += 1 } + state.closeAllSurfaces() + + // The dormant drain loop marks the lingering unread read and drops the state + // hibernation preserved for its unseen counter, so nothing strands the dot. + #expect(!state.hasUnseenNotification) + #expect(state.surfaceStates[surface] == nil) + // A full teardown skips the per-state indicator callback; the manager + // reconciles the aggregate count once after tearing every state down. + #expect(indicatorFires == 0) + } + + @Test func osc9ForUnknownSurfaceIsDropped() async { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + state.hibernateTab(tab) + + state.deliverDormantOSCForTesting(surfaceID: UUID(), sequence: osc(9, "Ghost")) + await pump() + + #expect(!state.hasUnseenNotification) + #expect(state.unreadNotifications().isEmpty) + } + + @Test func conEmuShapedOSC9IsDroppedNotNotified() async { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + state.hibernateTab(tab) + + // A ConEmu progress-shaped payload (leading small-integer subcommand) is not a + // notification body; it must be dropped, not toasted. + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(9, "4;50")) + await pump() + #expect(!state.hasUnseenNotification) + #expect(state.unreadNotifications().isEmpty) + + // A real notification body still delivers. + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(9, "Build finished")) + await waitUntil { state.hasUnseenNotification } + #expect(state.unreadNotifications().contains { $0.surfaceID == surface && $0.body == "Build finished" }) + } + + @Test func isConEmuOSC9PayloadClassifiesSubcommandsVsBodies() { + #expect(WorktreeTerminalState.isConEmuOSC9Payload("4;50")) + #expect(WorktreeTerminalState.isConEmuOSC9Payload("1;420")) + #expect(WorktreeTerminalState.isConEmuOSC9Payload("12")) + // Out of the 1...12 subcommand range, or plainly a body: delivered. + #expect(!WorktreeTerminalState.isConEmuOSC9Payload("13;done")) + #expect(!WorktreeTerminalState.isConEmuOSC9Payload("Build finished")) + #expect(!WorktreeTerminalState.isConEmuOSC9Payload("Done")) + #expect(!WorktreeTerminalState.isConEmuOSC9Payload("")) + } + + // MARK: - OSC 3008 presence + + @Test func osc3008PresenceForDormantLeafReachesHookFunnel() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + var events: [AgentHookEvent] = [] + state.onAgentHookEvent = { events.append($0) } + + state.hibernateTab(tab) + // A busy -> idle transition: both reach the same funnel a live surface feeds. + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(3008, "start=claude;event=busy")) + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(3008, "start=claude;event=idle")) + + #expect(events.count == 2) + #expect(events.allSatisfy { $0.surfaceID == surface && $0.agent == "claude" }) + #expect(events.map(\.event) == ["busy", "idle"]) + } + + @Test func osc3008ForUnknownSurfaceIsDropped() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + var events: [AgentHookEvent] = [] + state.onAgentHookEvent = { events.append($0) } + + state.hibernateTab(tab) + state.deliverDormantOSCForTesting(surfaceID: UUID(), sequence: osc(3008, "start=claude;event=busy")) + + #expect(events.isEmpty) + } + + // MARK: - Title + + @Test func titleOSCForDormantLeafUpdatesTabRow() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(2, "dormant-title")) + + #expect(state.tabManager.tabs.first { $0.id == tab }?.title == "dormant-title") + } + + @Test func titleOSCForNonFocusedDormantLeafIsIgnored() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let firstID = firstSurfaceID(state, tab: tab) + #expect(state.performSplitAction(.newSplit(direction: .right), for: firstID)) + #expect(state.surfaceIDs(inTab: tab).count == 2) + + state.hibernateTab(tab) + + // Resolve the frozen focused vs non-focused leaf from the dormant snapshot. + let dormantTab = state.captureLayoutSnapshot()?.tabs.first { $0.id == tab.rawValue } + let leaves = dormantTab?.layout.leafSurfaceIDs ?? [] + #expect(leaves.count == 2) + let focused = leaves[dormantTab?.focusedLeafIndex ?? 0] + guard let nonFocused = leaves.first(where: { $0 != focused }) else { + Issue.record("Expected a non-focused leaf") + return + } + let titleBefore = state.tabManager.tabs.first { $0.id == tab }?.title + + // Only the focused leaf drives the row title; a non-focused leaf's OSC is ignored. + state.deliverDormantOSCForTesting(surfaceID: nonFocused, sequence: osc(2, "non-focused-title")) + + #expect(state.tabManager.tabs.first { $0.id == tab }?.title == titleBefore) + } + + @Test func emptyTitleOSCForDormantFocusedLeafIsIgnored() { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + let titleBefore = state.tabManager.tabs.first { $0.id == tab }?.title + + // A whitespace-only title is skipped so the row keeps its prior title. + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(2, " ")) + + #expect(state.tabManager.tabs.first { $0.id == tab }?.title == titleBefore) + } + + // MARK: - Wake / close overlap + + @Test func oscForNowLiveSurfaceProcessesOnceWithoutDuplication() async { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + state.wakeTab(tab) + // The surface is live again; a late watcher delivery is the only source of + // this OSC (zmx replays screen state, not the raw sequence), so it lands once. + #expect(state.surfaceIDs(inTab: tab) == [surface]) + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(9, "late")) + await waitUntil { state.hasUnseenNotification } + + #expect(state.unreadNotifications().filter { $0.surfaceID == surface }.count == 1) + #expect(state.surfaceIDs(inTab: tab) == [surface]) + } + + @Test func oscForJustClosedSurfaceIsCleanlyDropped() async { + let state = makeZmxState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTab(tab) + state.closeTab(tab) + + state.deliverDormantOSCForTesting(surfaceID: surface, sequence: osc(9, "gone")) + await pump() + + #expect(!state.hasTab(tab)) + #expect(state.dormantTabLayouts[tab] == nil) + #expect(state.unreadNotifications().isEmpty) + } + + @Test func hibernationDuringUnexpectedCloseProbeIsInert() async { + let killed = LockIsolated<[String]>([]) + let gate = LockIsolated?>(nil) + let state = withDependencies { + $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 0) + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { id in killed.withValue { $0.append(id) } }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { + await withCheckedContinuation { gate.setValue($0) } + return [] + } + ) + } operation: { + WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + splitPreserveZoomOnNavigation: { false } + ) + } + let tab = state.createTab(focusing: false)! + let view = state.splitTree(for: tab).root!.leftmostLeaf() + let surface = view.id + + // An unexpected close dispatches the ownership probe, suspended on the gate. + view.bridge.onCloseRequest?(false) + await waitUntil { gate.value != nil } + + // The tab hibernates while the probe is in flight, tearing the surface down. + state.hibernateTab(tab) + #expect(state.dormantTabLayouts[tab] != nil) + + // The probe resolves for a surface that is no longer live: the + // `surfaces[id] === view` guard makes it fully inert (no kill, no replace, + // no resurrection). + gate.value?.resume() + await pump() + + #expect(killed.value.isEmpty) + #expect(state.dormantTabLayouts[tab] != nil) + // The state is preserved through hibernation with a zero counter; the inert + // probe neither resurrects a live surface nor mutates it. + #expect(state.surfaceStates[surface]?.unseenNotificationCount == 0) + #expect(state.surfaceIDs(inTab: tab) == [surface]) + } + + // MARK: - OSC 3008 payload split + + @Test func contextSignalFieldsSplitsIDAndMetadata() { + let parsed = WorktreeTerminalState.contextSignalFields(payload: "start=claude;event=busy;pid=42") + #expect(parsed?.id == "claude") + #expect(parsed?.metadata == "event=busy;pid=42") + + let idOnly = WorktreeTerminalState.contextSignalFields(payload: "end=claude") + #expect(idOnly?.id == "claude") + #expect(idOnly?.metadata == "") + + // No start=/end= prefix, empty id, and over-length id are rejected. + #expect(WorktreeTerminalState.contextSignalFields(payload: "bogus=claude") == nil) + #expect(WorktreeTerminalState.contextSignalFields(payload: "start=") == nil) + #expect(WorktreeTerminalState.contextSignalFields(payload: "start=\(String(repeating: "a", count: 65))") == nil) + } +} + +/// The hibernation Beta gate: the scheduling and fire paths must stay inert while +/// the opt-in flag is off, and a flip must re-arm or cancel accordingly. Isolated +/// via `.dependencies` so this suite's `false` writes to the shared settings never +/// race the parallel suites that seed the flag on. +@MainActor +@Suite(.serialized, .dependencies) +struct HibernationBetaGateTests { + private func makeWorktree() -> Worktree { + Worktree( + id: WorktreeID("/tmp/repo/wt-hib-gate"), + name: "wt-hib-gate", + detail: "detail", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/wt-hib-gate"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + /// A zmx-eligible state on a real hibernation clock, so a scheduled grace timer + /// stays pending (never fires) and the scheduled set is assertable. + private func makeState() -> WorktreeTerminalState { + withDependencies { + $0.continuousClock = ImmediateClock() + $0.date.now = Date(timeIntervalSince1970: 0) + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { _ in }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + } operation: { + WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + splitPreserveZoomOnNavigation: { false } + ) + } + } + + @Test func hiddenTabDoesNotScheduleWhenDisabled() { + HibernationTestSupport.setHibernation(false) + let state = makeState() + let tab = state.createTab(focusing: false)! + #expect(!state.scheduledHibernationTabsForTesting.contains(tab)) + } + + @Test func hiddenTabSchedulesWhenEnabled() { + HibernationTestSupport.setHibernation(true) + let state = makeState() + let tab = state.createTab(focusing: false)! + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + } + + @Test func disablingCancelsPendingTimers() { + HibernationTestSupport.setHibernation(true) + let state = makeState() + let tab = state.createTab(focusing: false)! + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + + state.applyHibernationEnabled(false) + #expect(state.scheduledHibernationTabsForTesting.isEmpty) + } + + @Test func enablingSchedulesHiddenTabs() { + HibernationTestSupport.setHibernation(false) + let state = makeState() + let tab = state.createTab(focusing: false)! + #expect(state.scheduledHibernationTabsForTesting.isEmpty) + + state.applyHibernationEnabled(true) + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + } + + @Test func firingWhileDisabledDoesNotHibernate() { + HibernationTestSupport.setHibernation(true) + let state = makeState() + let tab = state.createTab(focusing: false)! + + // Flip off mid-window: the fire-time re-check must skip hibernation without re-arming. + state.applyHibernationEnabled(false) + state.fireHibernationTimerForTesting(tab) + + #expect(state.dormantTabLayouts[tab] == nil) + #expect(!state.scheduledHibernationTabsForTesting.contains(tab)) + } + + @Test func managerFanOutTogglesTimersAcrossStates() { + let manager = withDependencies { + $0.zmxClient = ZmxClient( + executableURL: { URL(fileURLWithPath: "/usr/bin/true") }, + isBundled: { true }, + killSession: { _ in }, + killRemoteSession: { _, _ in }, + listSessionsWithClients: { [] } + ) + $0.settingsFileStorage = .inMemory() + } operation: { + WorktreeTerminalManager(runtime: GhosttyRuntime(), clock: TestClock()) + } + manager.saveLayoutSnapshot = { _, _ in } + let worktree = makeWorktree() + let state = manager.state(for: worktree) + // Drive the flag purely through the fan-out command (the production path). + manager.handleCommand(.setTerminalHibernationEnabled(true)) + let tab = state.createTab(focusing: false)! + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + + // Flag off fans out a cancel to every state. + manager.handleCommand(.setTerminalHibernationEnabled(false)) + #expect(state.scheduledHibernationTabsForTesting.isEmpty) + + // Flag on re-arms grace timers for the still-hidden tab. + manager.handleCommand(.setTerminalHibernationEnabled(true)) + #expect(state.scheduledHibernationTabsForTesting.contains(tab)) + } +} diff --git a/supacodeTests/ZmxSessionWatcherTests.swift b/supacodeTests/ZmxSessionWatcherTests.swift new file mode 100644 index 000000000..76bcb093b --- /dev/null +++ b/supacodeTests/ZmxSessionWatcherTests.swift @@ -0,0 +1,665 @@ +import ConcurrencyExtras +import Darwin +import Dependencies +import Foundation +import SupacodeSettingsShared +import Testing + +@testable import supacode + +/// Byte fixtures mirroring zmx's IPC framing and OSC sequences, built from the +/// wire shapes in `ThirdParty/zmx/src/ipc.zig`. +private enum ZmxWireFixture { + static let esc: UInt8 = 0x1B + static let bel: UInt8 = 0x07 + static let backslash: UInt8 = 0x5C + static let openBracket: UInt8 = 0x5D + + /// An 8-byte header (tag, little-endian u32 length, 3 padding bytes) followed + /// by the payload, matching `asBytes(&Header)` with `@sizeOf(Header) == 8`. + static func frame(tag: UInt8, payload: [UInt8]) -> [UInt8] { + let length = UInt32(payload.count) + var bytes: [UInt8] = [ + tag, + UInt8(length & 0xFF), + UInt8((length >> 8) & 0xFF), + UInt8((length >> 16) & 0xFF), + UInt8((length >> 24) & 0xFF), + 0, 0, 0, + ] + bytes.append(contentsOf: payload) + return bytes + } + + /// A raw 8-byte header with an arbitrary declared length, for the oversized + /// desync case (no real payload appended). + static func header(tag: UInt8, declaredLength: UInt32) -> [UInt8] { + [ + tag, + UInt8(declaredLength & 0xFF), + UInt8((declaredLength >> 8) & 0xFF), + UInt8((declaredLength >> 16) & 0xFF), + UInt8((declaredLength >> 24) & 0xFF), + 0, 0, 0, + ] + } + + static func oscBEL(_ code: Int, _ data: String) -> [UInt8] { + var bytes: [UInt8] = [esc, openBracket] + bytes.append(contentsOf: Array("\(code);\(data)".utf8)) + bytes.append(bel) + return bytes + } + + static func oscST(_ code: Int, _ data: String) -> [UInt8] { + var bytes: [UInt8] = [esc, openBracket] + bytes.append(contentsOf: Array("\(code);\(data)".utf8)) + bytes.append(contentsOf: [esc, backslash]) + return bytes + } + + static func bytes(_ string: String) -> [UInt8] { Array(string.utf8) } +} + +// MARK: - OSC scanner + +@Suite(.serialized) +struct ZmxOSCScannerTests { + @Test func extractsSingleBELTerminatedSequence() { + var scanner = ZmxOSCScanner() + let result = scanner.scan(ZmxWireFixture.oscBEL(9, "hello")) + #expect(result == [ZmxOSCSequence(code: 9, payload: ZmxWireFixture.bytes("hello"))]) + } + + @Test func extractsSTTerminatedSequence() { + var scanner = ZmxOSCScanner() + let result = scanner.scan(ZmxWireFixture.oscST(0, "my title")) + #expect(result == [ZmxOSCSequence(code: 0, payload: ZmxWireFixture.bytes("my title"))]) + } + + @Test func parsesCodeOnlySequenceWithoutSeparator() { + var scanner = ZmxOSCScanner() + var seq: [UInt8] = [ZmxWireFixture.esc, ZmxWireFixture.openBracket] + seq.append(contentsOf: ZmxWireFixture.bytes("112")) + seq.append(ZmxWireFixture.bel) + let result = scanner.scan(seq) + #expect(result == [ZmxOSCSequence(code: 112, payload: [])]) + } + + @Test func reassemblesSequenceSplitByteByByte() { + var scanner = ZmxOSCScanner() + var collected: [ZmxOSCSequence] = [] + for byte in ZmxWireFixture.oscBEL(3008, "{\"event\":\"idle\"}") { + collected.append(contentsOf: scanner.scan([byte])) + } + #expect(collected == [ZmxOSCSequence(code: 3008, payload: ZmxWireFixture.bytes("{\"event\":\"idle\"}"))]) + } + + @Test func reassemblesSTTerminatorSplitAcrossReads() { + var scanner = ZmxOSCScanner() + // Split between the ESC and the `\` of the ST terminator. + let full = ZmxWireFixture.oscST(7, "file:///tmp") + let head = Array(full.dropLast()) + let tail = [full.last!] + #expect(scanner.scan(head).isEmpty) + let result = scanner.scan(tail) + #expect(result == [ZmxOSCSequence(code: 7, payload: ZmxWireFixture.bytes("file:///tmp"))]) + } + + @Test func discardsInterleavedNonOSCBytes() { + var scanner = ZmxOSCScanner() + var stream = ZmxWireFixture.bytes("plain shell output\r\n") + stream.append(contentsOf: ZmxWireFixture.oscBEL(9, "notify")) + stream.append(contentsOf: ZmxWireFixture.bytes("more output")) + let result = scanner.scan(stream) + #expect(result == [ZmxOSCSequence(code: 9, payload: ZmxWireFixture.bytes("notify"))]) + } + + @Test func emitsMultipleSequencesInOneChunk() { + var scanner = ZmxOSCScanner() + var stream = ZmxWireFixture.oscBEL(0, "title") + stream.append(contentsOf: ZmxWireFixture.oscST(3008, "busy")) + stream.append(contentsOf: ZmxWireFixture.oscBEL(9, "done")) + let result = scanner.scan(stream) + #expect( + result == [ + ZmxOSCSequence(code: 0, payload: ZmxWireFixture.bytes("title")), + ZmxOSCSequence(code: 3008, payload: ZmxWireFixture.bytes("busy")), + ZmxOSCSequence(code: 9, payload: ZmxWireFixture.bytes("done")), + ] + ) + } + + @Test func dropsOversizedSequenceWithoutLosingTheNextOne() { + var scanner = ZmxOSCScanner() + var stream = ZmxWireFixture.oscBEL(9, String(repeating: "x", count: ZmxOSCScanner.maxSequenceLength + 500)) + stream.append(contentsOf: ZmxWireFixture.oscBEL(3008, "ok")) + let result = scanner.scan(stream) + #expect(result == [ZmxOSCSequence(code: 3008, payload: ZmxWireFixture.bytes("ok"))]) + } + + @Test func abandonsSequenceOnMalformedInnerEscape() { + var scanner = ZmxOSCScanner() + // ESC inside the OSC not followed by `\` abandons the sequence; a fresh + // ESC ] then opens the next, which is emitted. + var stream: [UInt8] = [ZmxWireFixture.esc, ZmxWireFixture.openBracket] + stream.append(contentsOf: ZmxWireFixture.bytes("9;partial")) + stream.append(ZmxWireFixture.esc) + stream.append(0x41) // 'A', not backslash. + stream.append(contentsOf: ZmxWireFixture.oscBEL(2, "recovered")) + let result = scanner.scan(stream) + #expect(result == [ZmxOSCSequence(code: 2, payload: ZmxWireFixture.bytes("recovered"))]) + } + + @Test func dropsNonNumericCode() { + var scanner = ZmxOSCScanner() + var seq: [UInt8] = [ZmxWireFixture.esc, ZmxWireFixture.openBracket] + seq.append(contentsOf: ZmxWireFixture.bytes("L;label")) + seq.append(ZmxWireFixture.bel) + #expect(scanner.scan(seq).isEmpty) + } +} + +// MARK: - IPC frame decoder + +@Suite(.serialized) +struct ZmxIPCFrameDecoderTests { + @Test func decodesSingleFrame() throws { + var decoder = ZmxIPCFrameDecoder() + let frame = ZmxWireFixture.frame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("hello")) + let frames = try decoder.decode(frame) + #expect(frames == [ZmxIPCFrame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("hello"))]) + } + + @Test func decodesTwoFramesInOneChunk() throws { + var decoder = ZmxIPCFrameDecoder() + var chunk = ZmxWireFixture.frame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("a")) + chunk.append(contentsOf: ZmxWireFixture.frame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("bb"))) + let frames = try decoder.decode(chunk) + #expect( + frames == [ + ZmxIPCFrame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("a")), + ZmxIPCFrame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("bb")), + ] + ) + } + + @Test func buffersPartialHeaderAcrossReads() throws { + var decoder = ZmxIPCFrameDecoder() + let frame = ZmxWireFixture.frame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("split")) + #expect(try decoder.decode(Array(frame.prefix(5))).isEmpty) + let frames = try decoder.decode(Array(frame.dropFirst(5))) + #expect(frames == [ZmxIPCFrame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("split"))]) + } + + @Test func buffersPartialPayloadAcrossReads() throws { + var decoder = ZmxIPCFrameDecoder() + let frame = ZmxWireFixture.frame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("payload")) + // Header plus two payload bytes, then the remainder. + #expect(try decoder.decode(Array(frame.prefix(ZmxIPCFrameDecoder.headerSize + 2))).isEmpty) + let frames = try decoder.decode(Array(frame.dropFirst(ZmxIPCFrameDecoder.headerSize + 2))) + #expect(frames == [ZmxIPCFrame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("payload"))]) + } + + @Test func yieldsUnknownTagForTheConsumerToSkip() throws { + var decoder = ZmxIPCFrameDecoder() + let unknownTag: UInt8 = 99 + let frame = ZmxWireFixture.frame(tag: unknownTag, payload: ZmxWireFixture.bytes("x")) + let frames = try decoder.decode(frame) + #expect(frames.count == 1) + #expect(frames[0].tag == unknownTag) + // A passive client keeps only `.Output`; the unknown tag is skipped. + #expect(frames.filter { $0.tag == ZmxIPCTag.output }.isEmpty) + } + + @Test func skipsEmptyPayloadFrame() throws { + var decoder = ZmxIPCFrameDecoder() + // A TaskComplete-style frame carrying a single byte, then an Output frame. + var chunk = ZmxWireFixture.frame(tag: 13, payload: [7]) + chunk.append(contentsOf: ZmxWireFixture.frame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("hi"))) + let frames = try decoder.decode(chunk) + #expect(frames.count == 2) + #expect(frames[0] == ZmxIPCFrame(tag: 13, payload: [7])) + #expect(frames[1] == ZmxIPCFrame(tag: ZmxIPCTag.output, payload: ZmxWireFixture.bytes("hi"))) + } + + @Test func throwsOnOversizedDeclaredLength() { + var decoder = ZmxIPCFrameDecoder() + let oversized = UInt32(ZmxIPCFrameDecoder.maxPayloadSize + 1) + let header = ZmxWireFixture.header(tag: ZmxIPCTag.output, declaredLength: oversized) + #expect(throws: ZmxIPCFrameDecoder.DecodeError.payloadTooLarge(oversized)) { + _ = try decoder.decode(header) + } + } +} + +// MARK: - Watcher lifecycle (fake socket server) + +/// Minimal AF_UNIX server that accepts one connection, writes canned framed +/// bytes, and holds the connection open so the watcher stays connected until +/// teardown. Deterministic: no real zmx involved. +private final class FakeZmxDaemon { + let path: String + private let listenFD: Int32 + private let stopped = LockIsolated(false) + + init?(explicitPath: String? = nil) { + self.path = explicitPath ?? "/tmp/zmxw-\(UUID().uuidString.prefix(8)).sock" + unlink(path) + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { return nil } + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = path.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + close(socketFD) + return nil + } + _ = withUnsafeMutablePointer(to: &addr.sun_path) { sunPath in + pathBytes.withUnsafeBufferPointer { buffer in + memcpy(sunPath, buffer.baseAddress!, buffer.count) + } + } + let addrLen = socklen_t(MemoryLayout.size + pathBytes.count) + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(socketFD, $0, addrLen) } + } + guard bindResult == 0, listen(socketFD, 4) == 0 else { + close(socketFD) + return nil + } + self.listenFD = socketFD + } + + /// Accepts one connection, writes `payload`, holds it open until `stop()`. + func serveOnce(payload: [UInt8]) { + serve(payload: payload, loop: false, closeAfterWrite: false) + } + + /// Accepts connections and writes `payload` to each. When `closeAfterWrite` + /// the client is closed right after the write (the watcher sees EOF or, for a + /// garbage payload, a desync); otherwise the connection is held open. When + /// `loop` the daemon keeps accepting so the watcher can reconnect. + func serve(payload: [UInt8], loop: Bool, closeAfterWrite: Bool) { + let listenFD = self.listenFD + let stopped = self.stopped + let thread = Thread { + var pollFD = pollfd(fd: listenFD, events: Int16(POLLIN), revents: 0) + while !stopped.value { + guard poll(&pollFD, 1, 100) > 0 else { continue } + let clientFD = accept(listenFD, nil, nil) + guard clientFD >= 0 else { continue } + // A reconnecting watcher may close its end mid-write; suppress SIGPIPE + // so the write just fails instead of killing the test process. + var noSIGPIPE: Int32 = 1 + _ = setsockopt( + clientFD, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, socklen_t(MemoryLayout.size)) + payload.withUnsafeBytes { raw in + if let base = raw.baseAddress { _ = write(clientFD, base, raw.count) } + } + if closeAfterWrite { + close(clientFD) + } else { + while !stopped.value { usleep(50_000) } + close(clientFD) + } + if !loop { return } + } + } + thread.name = "fake-zmx-daemon" + thread.start() + } + + /// Accepts connections and, per connection, writes `prelude`, waits past one + /// watcher poll so it lands in its own read, then writes `payload` and closes. + /// Lets the watcher deliver a valid frame before a desyncing garbage header in + /// the same cycle. `loop` keeps accepting so the watcher can reconnect. + func serveSplit(prelude: [UInt8], then payload: [UInt8], loop: Bool) { + let listenFD = self.listenFD + let stopped = self.stopped + let thread = Thread { + var pollFD = pollfd(fd: listenFD, events: Int16(POLLIN), revents: 0) + while !stopped.value { + guard poll(&pollFD, 1, 100) > 0 else { continue } + let clientFD = accept(listenFD, nil, nil) + guard clientFD >= 0 else { continue } + var noSIGPIPE: Int32 = 1 + _ = setsockopt( + clientFD, SOL_SOCKET, SO_NOSIGPIPE, &noSIGPIPE, socklen_t(MemoryLayout.size)) + prelude.withUnsafeBytes { raw in + if let base = raw.baseAddress { _ = write(clientFD, base, raw.count) } + } + // Longer than the watcher poll timeout so the prelude is read in its own + // cycle before the garbage arrives. + usleep(120_000) + payload.withUnsafeBytes { raw in + if let base = raw.baseAddress { _ = write(clientFD, base, raw.count) } + } + close(clientFD) + if !loop { return } + } + } + thread.name = "fake-zmx-daemon" + thread.start() + } + + func stop() { + stopped.setValue(true) + close(listenFD) + unlink(path) + } +} + +@MainActor +@Suite(.serialized) +struct ZmxSessionWatcherLifecycleTests { + /// Tiny backoff so give-up runs in milliseconds without real sleeps. + private static let fastTuning = ZmxSessionWatcher.Tuning( + maxConnectAttempts: 6, + baseBackoffMilliseconds: 1, + maxBackoffMilliseconds: 5, + pollTimeoutMilliseconds: 50, + minHealthyConnectionMilliseconds: 1000 + ) + + @Test func connectsReadsAndEmitsOSCSequences() { + guard let daemon = FakeZmxDaemon() else { + Issue.record("Failed to bind fake zmx daemon socket") + return + } + defer { daemon.stop() } + let frame = ZmxWireFixture.frame( + tag: ZmxIPCTag.output, + payload: ZmxWireFixture.oscBEL(9, "ping") + ) + daemon.serveOnce(payload: frame) + + let received = LockIsolated<[ZmxOSCSequence]>([]) + let semaphore = DispatchSemaphore(value: 0) + let watcher = ZmxSessionWatcher(surfaceID: UUID(), socketPath: daemon.path) { sequences in + received.withValue { $0.append(contentsOf: sequences) } + semaphore.signal() + } + watcher.start() + defer { watcher.stop() } + + #expect(semaphore.wait(timeout: .now() + 3) == .success) + #expect(received.value == [ZmxOSCSequence(code: 9, payload: ZmxWireFixture.bytes("ping"))]) + } + + @Test func startsAtMostOneThread() { + // A double start must not spawn a second reader; stop stays safe to call. + let watcher = ZmxSessionWatcher(surfaceID: UUID(), socketPath: "/tmp/zmxw-missing.sock") { _ in } + watcher.start() + watcher.start() + watcher.stop() + } + + @Test func reconnectsAndResumesAfterEOF() { + guard let daemon = FakeZmxDaemon() else { + Issue.record("Failed to bind fake zmx daemon socket") + return + } + defer { daemon.stop() } + let frame = ZmxWireFixture.frame( + tag: ZmxIPCTag.output, + payload: ZmxWireFixture.oscBEL(9, "ping") + ) + // Each accept writes one frame then drops the connection; the watcher must + // reconnect and keep delivering. + daemon.serve(payload: frame, loop: true, closeAfterWrite: true) + + let deliveries = LockIsolated(0) + let resumed = DispatchSemaphore(value: 0) + let watcher = ZmxSessionWatcher( + surfaceID: UUID(), socketPath: daemon.path, tuning: Self.fastTuning + ) { _ in + let count = deliveries.withValue { + $0 += 1 + return $0 + } + if count >= 2 { resumed.signal() } + } + watcher.start() + defer { watcher.stop() } + + #expect(resumed.wait(timeout: .now() + 3) == .success) + } + + @Test func givesUpAfterExhaustingBudgetOnDeadSocket() { + let finished = DispatchSemaphore(value: 0) + let delivered = LockIsolated(false) + let watcher = ZmxSessionWatcher( + surfaceID: UUID(), + socketPath: "/tmp/zmxw-nonexistent-\(UUID().uuidString.prefix(8)).sock", + tuning: Self.fastTuning, + onReaderFinished: { finished.signal() }, + onSequences: { _ in delivered.setValue(true) } + ) + watcher.start() + defer { watcher.stop() } + + #expect(finished.wait(timeout: .now() + 3) == .success) + #expect(delivered.value == false) + } + + @Test func givesUpWhenDaemonAlwaysDesyncs() { + guard let daemon = FakeZmxDaemon() else { + Issue.record("Failed to bind fake zmx daemon socket") + return + } + defer { daemon.stop() } + // Each cycle delivers a valid frame first, then an 8-byte header declaring an + // impossible payload length: decode throws `payloadTooLarge`. A desync must + // charge the budget even after a frame was delivered, so a chatty-but-corrupt + // daemon still gives up instead of resetting the budget and looping forever. + let prelude = ZmxWireFixture.frame( + tag: ZmxIPCTag.output, + payload: ZmxWireFixture.oscBEL(9, "ping") + ) + let garbage = ZmxWireFixture.header( + tag: ZmxIPCTag.output, + declaredLength: UInt32(ZmxIPCFrameDecoder.maxPayloadSize + 1) + ) + daemon.serveSplit(prelude: prelude, then: garbage, loop: true) + + let finished = DispatchSemaphore(value: 0) + let delivered = LockIsolated(false) + let watcher = ZmxSessionWatcher( + surfaceID: UUID(), socketPath: daemon.path, tuning: Self.fastTuning, + onReaderFinished: { finished.signal() }, + onSequences: { _ in delivered.setValue(true) } + ) + watcher.start() + defer { watcher.stop() } + + // Gave up despite delivering a valid frame in each cycle before desyncing. + #expect(finished.wait(timeout: .now() + 3) == .success) + #expect(delivered.value == true) + } + + @Test func startAfterStopIsANoOp() { + // Once stopped, start() must not spawn a reader; onReaderFinished never + // fires because run() never executes. + let finished = DispatchSemaphore(value: 0) + let watcher = ZmxSessionWatcher( + surfaceID: UUID(), + socketPath: "/tmp/zmxw-nonexistent-\(UUID().uuidString.prefix(8)).sock", + tuning: Self.fastTuning, + onReaderFinished: { finished.signal() }, + onSequences: { _ in } + ) + watcher.stop() + watcher.start() + + #expect(finished.wait(timeout: .now() + 0.3) == .timedOut) + } + + @Test func backoffMillisecondsStaysWithinBounds() { + #expect(ZmxSessionWatcher.backoffMilliseconds(1) == 200) + #expect(ZmxSessionWatcher.backoffMilliseconds(2) == 400) + #expect(ZmxSessionWatcher.backoffMilliseconds(3) == 800) + #expect(ZmxSessionWatcher.backoffMilliseconds(4) == 1600) + #expect(ZmxSessionWatcher.backoffMilliseconds(5) == 3200) + // Capped once the exponential exceeds the ceiling. + #expect(ZmxSessionWatcher.backoffMilliseconds(6) == 5000) + #expect(ZmxSessionWatcher.backoffMilliseconds(100) == 5000) + } +} + +// MARK: - State registry integration + +@MainActor +@Suite(.serialized) +struct ZmxDormantWatcherRegistryTests { + private func makeWorktree(id: String = "/tmp/repo/wt-watcher") -> Worktree { + Worktree( + id: WorktreeID(id), + name: URL(fileURLWithPath: id).lastPathComponent, + detail: "detail", + workingDirectory: URL(fileURLWithPath: id), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo") + ) + } + + private func makeState() -> WorktreeTerminalState { + WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: makeWorktree(), + splitPreserveZoomOnNavigation: { false } + ) + } + + private func firstSurfaceID(_ state: WorktreeTerminalState, tab: TerminalTabID) -> UUID { + state.splitTree(for: tab).root!.leftmostLeaf().id + } + + @Test func hibernateStartsWatchersForTabLeaves() { + let state = makeState() + let tab = state.createTab(focusing: false)! + let surface = firstSurfaceID(state, tab: tab) + + state.hibernateTabForTesting(tab) + + #expect(state.watchedDormantSurfaceIDsForTesting == [surface]) + } + + @Test func wakeStopsOnlyTheWokenTabsWatchers() { + let state = makeState() + let first = state.createTab(focusing: false)! + let firstSurface = firstSurfaceID(state, tab: first) + let second = state.createTab(focusing: false)! + let secondSurface = firstSurfaceID(state, tab: second) + + state.hibernateTabForTesting(first) + state.hibernateTabForTesting(second) + #expect(state.watchedDormantSurfaceIDsForTesting == [firstSurface, secondSurface]) + + state.wakeTab(first) + #expect(state.watchedDormantSurfaceIDsForTesting == [secondSurface]) + } + + @Test func closingDormantTabStopsItsWatchers() { + let state = makeState() + let tab = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: tab) + + state.hibernateTabForTesting(tab) + #expect(!state.watchedDormantSurfaceIDsForTesting.isEmpty) + + state.closeTab(tab) + #expect(state.watchedDormantSurfaceIDsForTesting.isEmpty) + } + + @Test func closeAllSurfacesStopsAllWatchers() { + let state = makeState() + let first = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: first) + let second = state.createTab(focusing: false)! + _ = firstSurfaceID(state, tab: second) + + state.hibernateTabForTesting(first) + state.hibernateTabForTesting(second) + #expect(state.watchedDormantSurfaceIDsForTesting.count == 2) + + state.closeAllSurfaces() + #expect(state.watchedDormantSurfaceIDsForTesting.isEmpty) + } + + @Test func watchedSetEqualsDormantLeavesAfterEveryMutation() { + let state = makeState() + let first = state.createTab(focusing: false)! + let firstSurface = firstSurfaceID(state, tab: first) + let second = state.createTab(focusing: false)! + let secondSurface = firstSurfaceID(state, tab: second) + + func dormantLeaves() -> Set { + Set(state.dormantTabLayouts.values.flatMap { $0.layout.leafSurfaceIDs }) + } + + state.hibernateTabForTesting(first) + #expect(state.watchedDormantSurfaceIDsForTesting == dormantLeaves()) + + state.hibernateTabForTesting(second) + #expect(state.watchedDormantSurfaceIDsForTesting == dormantLeaves()) + #expect(state.watchedDormantSurfaceIDsForTesting == [firstSurface, secondSurface]) + + state.wakeTab(first) + #expect(state.watchedDormantSurfaceIDsForTesting == dormantLeaves()) + + state.closeTab(second) + #expect(state.watchedDormantSurfaceIDsForTesting == dormantLeaves()) + #expect(state.watchedDormantSurfaceIDsForTesting.isEmpty) + } +} + +// MARK: - Registry delivery (real client end-to-end) + +@MainActor +@Suite(.serialized) +struct ZmxSessionWatcherRegistryDeliveryTests { + @Test func deliversSequenceThroughOnOSCSequenceOnMainActor() async { + let surfaceID = UUID() + let socketDir = "/tmp/zmxw-reg-\(UUID().uuidString.prefix(8))" + try? FileManager.default.createDirectory(atPath: socketDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(atPath: socketDir) } + let socketPath = "\(socketDir)/\(ZmxSessionID.make(surfaceID: surfaceID))" + + guard let daemon = FakeZmxDaemon(explicitPath: socketPath) else { + Issue.record("Failed to bind fake zmx daemon socket") + return + } + defer { daemon.stop() } + daemon.serveOnce( + payload: ZmxWireFixture.frame( + tag: ZmxIPCTag.output, + payload: ZmxWireFixture.oscBEL(9, "hi") + ) + ) + + let received = LockIsolated<[(UUID, ZmxOSCSequence)]>([]) + let delivered = DispatchSemaphore(value: 0) + let registry = withDependencies { + $0.zmxSessionWatcherClient = .liveValue + } operation: { + ZmxSessionWatcherRegistry(socketDirectory: socketDir) + } + registry.onOSCSequence = { id, sequence in + received.withValue { $0.append((id, sequence)) } + delivered.signal() + } + registry.reconcile(dormantSurfaceIDs: [surfaceID]) + defer { registry.stopAll() } + + // Block off the main actor so the registry's MainActor delivery Task can + // run while we await the signal. + let result: DispatchTimeoutResult = await withCheckedContinuation { continuation in + Thread.detachNewThread { + continuation.resume(returning: delivered.wait(timeout: .now() + 3)) + } + } + #expect(result == .success) + #expect(received.value.map(\.0) == [surfaceID]) + #expect(received.value.map(\.1) == [ZmxOSCSequence(code: 9, payload: ZmxWireFixture.bytes("hi"))]) + } +}