Files
Termind/Frameworks/TermindApplication/TerminalCommandMonitor.swift
T
Kaede Akino 8e3f1128da feat(sessions): add tmux persistence and terminal context tracking
Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection.

Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
2026-10-16 01:04:12 +08:00

116 lines
4.5 KiB
Swift

import Foundation
import Observation
import TermindDomain
public nonisolated struct TerminalCommandCompletion: Equatable, Sendable {
public var command: String?
public var exitCode: Int?
public var duration: TimeInterval
public init(command: String? = nil, exitCode: Int?, duration: TimeInterval) {
self.command = command
self.exitCode = exitCode
self.duration = duration
}
public var commandPreview: String? {
guard let command else { return nil }
let cleaned = command.unicodeScalars.map { scalar in
CharacterSet.controlCharacters.contains(scalar) ? " " : String(scalar)
}.joined().split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
guard !cleaned.isEmpty else { return nil }
return cleaned.count > 160 ? String(cleaned.prefix(159)) + "…" : cleaned
}
}
/// Per-terminal state, independent of history and transient view mounting.
@MainActor @Observable
public final class TerminalCommandMonitor {
public private(set) var unreadCompletionCount = 0
public var hasUnreadCompletion: Bool { unreadCompletionCount > 0 }
public var preferences: () -> NotificationPreferences = { .init() }
public var isWorkspaceFocused: () -> Bool = { false }
public var alert: (TerminalCommandCompletion, NotificationPreferences) -> Void = { _, _ in }
private let now: () -> TimeInterval
private var active = false
private var usesShellIntegration = false
private var running: (command: String, started: TimeInterval)?
private var contextualRunning: [TerminalContextIdentity: (command: String, started: TimeInterval)] = [:]
private var acceptsUnscopedCompletion = true
public init(now: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }) {
self.now = now
}
public func beginConnection() {
active = true
usesShellIntegration = false
running = nil
contextualRunning.removeAll()
acceptsUnscopedCompletion = true
}
public func endConnection() {
active = false
running = nil
usesShellIntegration = false
contextualRunning.removeAll()
}
public func markRead() { unreadCompletionCount = 0 }
public func receiveShellEvent(_ event: SSHShellIntegrationEvent) {
guard active else { return }
switch event {
case .available:
usesShellIntegration = true
case .commandStarted(let command):
guard usesShellIntegration else { return }
running = (command, now())
case .commandFinished(let status, let reported):
guard usesShellIntegration, let command = running else { return }
running = nil
let duration =
reported.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
?? max(0, now() - command.started)
complete(.init(command: command.command, exitCode: Int(status), duration: duration))
default: break
}
}
public func receiveOSC133(exitCode: Int?, durationNanos: UInt64) {
guard active, acceptsUnscopedCompletion, !usesShellIntegration else { return }
complete(.init(exitCode: exitCode, duration: Double(durationNanos) / 1_000_000_000))
}
public func requireScopedEvents() {
acceptsUnscopedCompletion = false
}
public func receiveRuntimeEvents(_ events: [TerminalContextEvent]) {
guard active else { return }
acceptsUnscopedCompletion = false
for event in events {
switch event.payload {
case .commandStarted(let command):
contextualRunning[event.context] = (command, now())
case .commandFinished(let status, let reported):
guard let command = contextualRunning.removeValue(forKey: event.context) else { continue }
let duration =
reported.flatMap { $0.isFinite && $0 >= 0 ? $0 : nil }
?? max(0, now() - command.started)
complete(.init(command: command.command, exitCode: Int(status), duration: duration))
default: break
}
}
}
private func complete(_ completion: TerminalCommandCompletion) {
let focused = isWorkspaceFocused()
unreadCompletionCount = focused ? 0 : unreadCompletionCount + 1
let settings = preferences()
guard settings.allowsTerminal(duration: completion.duration, isFocused: focused) else { return }
alert(completion, settings)
}
}