Skip to content
6 changes: 5 additions & 1 deletion SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -136,6 +137,7 @@ public struct SettingsFeature {
terminateSessionsOnQuit = settings.terminateSessionsOnQuit
remoteSessionPersistenceEnabled = settings.remoteSessionPersistenceEnabled
appVisibility = settings.appVisibility
terminalHibernationEnabled = settings.terminalHibernationEnabled
defaultWorktreeBaseDirectoryPath =
SupacodePaths.normalizedWorktreeBaseDirectoryPath(settings.defaultWorktreeBaseDirectoryPath) ?? ""
}
Expand Down Expand Up @@ -179,7 +181,8 @@ public struct SettingsFeature {
confirmCloseSurface: confirmCloseSurface,
terminateSessionsOnQuit: terminateSessionsOnQuit,
remoteSessionPersistenceEnabled: remoteSessionPersistenceEnabled,
appVisibility: appVisibility
appVisibility: appVisibility,
terminalHibernationEnabled: terminalHibernationEnabled
)
}
}
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions SupacodeSettingsFeature/Views/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}
11 changes: 10 additions & 1 deletion SupacodeSettingsShared/Models/GlobalSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
3 changes: 3 additions & 0 deletions supacode/Clients/Terminal/TerminalClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
65 changes: 65 additions & 0 deletions supacode/Clients/Zmx/ZmxIPCFrameDecoder.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
130 changes: 130 additions & 0 deletions supacode/Clients/Zmx/ZmxOSCScanner.swift
Original file line number Diff line number Diff line change
@@ -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 ] <code> [; data] <terminator>`,
/// 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 `<code>[;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[..<separator]) else { return nil }
return ZmxOSCSequence(code: code, payload: Array(bytes[bytes.index(after: separator)...]))
}
guard let code = parseCode(bytes[...]) else { return nil }
return ZmxOSCSequence(code: code, payload: [])
}

/// Parses an all-digit code slice; nil for empty or non-numeric input. Capped
/// at six digits since every OSC code the app cares about is <= 3008.
private static func parseCode(_ bytes: ArraySlice<UInt8>) -> 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
}
}
Loading
Loading