Files
Termind/TFM/App/AppModel.swift
T
Kaede Akino 1ab7ed3da3 feat: isolate tailscale connections and harden history
Bind independent Tailnet connections to group IDs while preserving legacy host identities through the V3 sync format. Persist profile cleanup retries, isolate test storage, and reject stale dialers after re-enrollment.

Recover quarantined sync records safely, refresh metrics compatibility, fix multiline shell history, and remove obsolete mobile history gates. Pin libtailscale-spm to 1.0.1 alongside its submodule revision.

Validation: default macOS suites (2679 tests), targeted iOS suites (26 tests), strict Swift formatting, shared AppStrings checks, and Chinese localization checks passed. Both subagent reviews approved.
2026-10-03 09:46:09 +08:00

1400 lines
65 KiB
Swift

import AgentKit
import AgentKitScrubber
import AppKit
import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import struct TermindDomain.Host
/// The ways a host can be opened. Centralised so the sidebar, the workspace bar's `+`
/// menu, and the menu bar all take the same code path — including the bookkeeping
/// (mark as recently used, flip the status dot) that is easy to forget in one of
/// three places.
/// There is no "open into the current split" here, deliberately. A Workspace is
/// assembled from things already open — drag one workspace onto another — so opening a
/// host never has to know about layout, and a host you have never opened can
/// never appear welded into whatever workspace happened to be front.
/// Where a batch of hosts lands: a workspace each, or one Workspace holding all of
/// them. Bringing a dozen machines up is usually so you can *watch* them, and
/// watching them means having them on screen at once.
nonisolated enum BatchLayout: Hashable, Sendable {
case separateWorkspaces
case oneWorkspace
}
nonisolated enum HostOpenTarget: Hashable, Sendable {
case preferredTerminal
case sshSession
case telnetSession
case sftpChannel
}
/// App-wide state shared by every window: the saved hosts, and the one code path
/// for opening them.
///
/// Everything the user can have in a *different* state in a second window —
/// workspaces, sidebar selection, search text, Sidebar visibility — lives on
/// `WindowModel` instead.
@Observable
final class AppModel {
private var terminationTask: Task<Void, Never>?
private var isTerminating = false
private final class WeakWindow {
weak var value: WindowModel?
init(_ value: WindowModel) { self.value = value }
}
private final class WeakAssistantChatCoordinator {
weak var value: AssistantChatCoordinator?
}
private let hosts: HostLibraryModel
let proAccess: ProAccessController
let tailscale: TailscaleModel
var hostLibrary: HostLibraryModel { hosts }
let knownHosts: KnownHostsLibraryModel
let credentials: CredentialLibraryModel
let snippets: SnippetLibraryModel
let forwardings: ForwardingLibraryModel
let transfers: TransferLibraryModel
let connectionHistory: ConnectionHistoryModel
let devices: DeviceDirectoryModel
private let externalToolCalls: ToolAuditModel
var toolAudit: ToolAuditModel { externalToolCalls }
let externalAuthentication: ExternalAuthCoordinator
let mcpServer: MCPServerController
let commands = AppCommandRegistry()
let onboarding: OnboardingController
let appReview: AppReviewController
let diagnosis: DiagnosisController
let serverMetrics: ServerMetricsModel
let windowSizeStore: MainWindowSizeStore
let logging: any ApplicationLogManaging
let notifications = SystemNotifications()
let notificationSettings: NotificationSettingsModel
@ObservationIgnored private lazy var notificationRouter = DesktopNotificationRouter(app: self)
@ObservationIgnored private var notificationBadge: NotificationBadgeCoordinator?
@ObservationIgnored private lazy var terminalNotifications = TerminalNotificationCoordinator(
service: notifications,
preferences: { [weak self] in self?.notificationSettings.preferences ?? .init() },
isFocused: { [weak self] in self?.isTerminalNotificationFocused($0) ?? false },
title: { [weak self] id in
guard let self, let (_, workspace, _) = self.commandNotificationTarget(id) else { return nil }
return workspace.title(in: self.hostLibrary, locale: .current)
},
body: CommandNotificationContent.body,
ringBell: TerminalNotificationPlatform.ringBell)
@ObservationIgnored private lazy var assistantNotifications = AssistantNotificationCoordinator(
service: notifications,
preferences: { [weak self] in self?.notificationSettings.preferences ?? .init() },
isFocused: { [weak self] in self?.isAssistantNotificationFocused($0) ?? false },
isVisible: { [weak self] in self?.isAssistantConversationVisible($0) ?? false },
content: AssistantNotificationContent.make)
@ObservationIgnored lazy var mainWindows = MainWindowCoordinator(app: self, sizes: windowSizeStore)
let connections: RemoteConnectionCoordinator
let serialPorts: any SerialPortServing
let hostRepository: any HostRepository
let settingsSync: SettingsSyncCoordinator?
let metricsSync: MetricsSyncCoordinator?
let vaultSync: VaultSyncCoordinator?
let connectionHistorySync: ConnectionHistorySyncCoordinator?
let deviceSync: DeviceSyncCoordinator?
let forwarding: PortForwardingController
let sftpEdits: SFTPEditService
let sftpTransfers: SFTPTransferCoordinator
@ObservationIgnored private var sftpNotifications: SFTPNotificationCoordinator?
/// Providers, models, role assignments, MCP servers.
///
/// Its own store rather than more fields on `HostLibraryModel`: that one is about
/// machines you connect to, this one about endpoints you spend a key at.
/// See `AIConfigurationModel`.
private let ai: AIConfigurationModel
var aiConfiguration: AIConfigurationModel { ai }
/// The procedures the user installed for the agent to follow. Its own store
/// rather than more fields on `ai`: that one is about endpoints you spend a
/// key at, this is a library of documents. See `SkillLibraryModel`.
let terminalHighlightSettings: TerminalHighlightSettingsModel
let terminalSettings: TerminalSettingsModel
let sshSettings: SSHSettingsModel
let sftpSettings: SFTPSettingsModel
let workspaceSettings: WorkspaceSettingsModel
let skills: SkillLibraryModel
let memory: MemoryModel
let commandGenerator: CommandGeneratorController
/// Names conversations for the chat list. Held as the protocol, not the
/// concrete controller, so a test can watch the trigger without a provider.
var conversationTitles: any ConversationTitling
/// One app-wide microphone owner. Keeping this above individual composers
/// prevents two windows from installing competing audio taps.
let speechTranscription: SpeechTranscriptionCoordinator
/// The one set of conversations every window navigates. See
/// `AssistantConversationStore`.
private let assistantConversations: AssistantConversationStore
private let agentRunRepository: any AgentRunPersisting
let assistantModelSelections: AssistantModelSelectionController
private let assistantChatCollaborationStore: any AssistantChatCollaborationStoring
@ObservationIgnored private let assistantChatCoordinatorBox = WeakAssistantChatCoordinator()
private let agentHosts: AgentHostResolver
private let agentConnections: AgentConnectionPool
private let agentAuthentication: AgentSSHAuthenticationRegistry
/// Commands that outlived the turn that started them. App-wide rather than
/// per-run, because a handle taken in one turn is answered in a later one
/// and a run ending is not a reason to stop a deploy.
private let agentBackgroundCommands: AgentBackgroundCommands
private let outboxRunner: OutboxRunner?
private let settingsDefaults: UserDefaults
private let shutdownDependencies: AppShutdownDependencies?
private var windows: [ObjectIdentifier: WeakWindow] = [:]
private var deletedAgentConversationIDs = Set<UUID>()
/// Conversations already handed to the summarizer. Titling is once per
/// conversation, and every run re-arms the hook that would do it.
func makeSSHImportModel() -> SSHImportModel {
SSHImportModel(
library: hosts, reader: SSHImportReader(),
storage: newVaultPasswordStorage)
}
var newVaultKeyStorage: KeyStorage {
NewVaultCredentialStorage.keyStorage(
defaults: settingsDefaults,
canUseVaultSync: proAccess.canUse(.vaultSync))
}
var newVaultPasswordStorage: PasswordStorage {
NewVaultCredentialStorage.passwordStorage(
defaults: settingsDefaults,
canUseVaultSync: proAccess.canUse(.vaultSync))
}
init(
hosts: HostLibraryModel, ai: AIConfigurationModel = AIConfigurationModel(),
skills: SkillLibraryModel = SkillLibraryModel(),
memory: MemoryModel = MemoryModel(),
terminalHighlightSettings: TerminalHighlightSettingsModel = TerminalHighlightSettingsModel(),
terminalSettings: TerminalSettingsModel = TerminalSettingsModel(),
notificationSettings: NotificationSettingsModel = NotificationSettingsModel(),
sshSettings: SSHSettingsModel = SSHSettingsModel(),
sftpSettings: SFTPSettingsModel = SFTPSettingsModel(),
workspaceSettings: WorkspaceSettingsModel = WorkspaceSettingsModel(),
connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(),
devices: DeviceDirectoryModel? = nil,
tailscaleStore: (any TailscaleLocalStoring)? = nil,
tailscaleFactory: @escaping TailscaleNodeFactory = { _, _, _, _ in
throw TailscaleConfigurationError.unavailable
},
tailscaleProfileStorage: TailscaleProfileStorage = .disabled,
externalToolCalls: ToolAuditModel = ToolAuditModel(),
dependencies: AppDependencies? = nil,
speechTranscription: SpeechTranscriptionCoordinator? = nil,
proAccess: ProAccessController = .unrestrictedForTesting(),
onboarding: OnboardingController = OnboardingController(),
appReview: AppReviewController = AppReviewController(),
diagnosis: DiagnosisController = DiagnosisController(),
serverMetrics suppliedMetrics: ServerMetricsModel? = nil,
windowSizeStore: MainWindowSizeStore = MainWindowSizeStore()
) {
self.windowSizeStore = windowSizeStore
let dependencies = dependencies ?? .inMemory(hosts: hosts.hosts, groups: hosts.groups)
settingsDefaults = dependencies.settingsDefaults
assistantConversations = dependencies.assistantConversations ?? AssistantConversationStore()
shutdownDependencies = dependencies.shutdown
logging = dependencies.logging
let externalAuthentication = ExternalAuthCoordinator(
logger: logging.makeLogger(label: "termind.external-mcp"))
self.externalAuthentication = externalAuthentication
let connectionCoordinator = dependencies.connectionCoordinator.withKeyboardInteractive(
externalAuthentication.keyboardInteractive)
connectionCoordinator.updateAgentForwarding(
VaultSSHAgent.factory(library: hosts, securityKeySigning: externalAuthentication.securityKeySigning))
connectionCoordinator.updateSSHPreferences(sshSettings.preferences)
self.serverMetrics =
suppliedMetrics
?? ServerMetricsModel(
hosts: hosts, preferences: dependencies.metricsPreferences ?? MetricsPreferences(),
makeSampler: { connectionCoordinator.makeMetricsSampler() },
cardLimit: { proAccess.canUse(.unlimitedMetrics) ? nil : ServerMetricsModel.freeCardLimit },
limitReached: { proAccess.require(.unlimitedMetrics) }
)
self.hosts = hosts
self.proAccess = proAccess
self.onboarding = onboarding
self.appReview = appReview
self.diagnosis = diagnosis
knownHosts = KnownHostsLibraryModel(library: hosts)
credentials = CredentialLibraryModel(library: hosts)
snippets = SnippetLibraryModel(library: hosts)
forwardings = ForwardingLibraryModel(library: hosts)
transfers = TransferLibraryModel(library: hosts)
self.connectionHistory = connectionHistory
let devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID)
self.devices = devices
tailscale = TailscaleModel(
library: hosts, store: tailscaleStore ?? InMemoryTailscaleLocalStore(),
factory: tailscaleFactory, profileStorage: tailscaleProfileStorage,
hostname: { devices.currentDeviceName }, hasAccess: { proAccess.canUse(.tailscale) }
)
self.externalToolCalls = externalToolCalls
connections = connectionCoordinator
serialPorts = dependencies.serialPortService
agentRunRepository = MemoryIndexedAgentRepository(
base: dependencies.agentRunRepository ?? InMemoryAgentRunRepository(), memory: memory)
assistantModelSelections = AssistantModelSelectionController(
ai: ai,
store: dependencies.assistantModelSelectionStore ?? InMemoryAssistantModelSelectionStore())
assistantChatCollaborationStore =
dependencies.assistantChatCollaborationStore ?? InMemoryAssistantChatCollaborationStore()
let sftpPreferences = MultiConnectionPolicy(
allowsMultipleConnections: proAccess.canUse(.sftpMultiConnection)
).sftpPreferences(sftpSettings.preferences)
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpPreferences)
let agentHostServices = AgentHostServices.live(
store: hosts,
coordinator: connectionCoordinator,
sftpConcurrencyScheduler: sftpConcurrencyScheduler)
agentHosts = agentHostServices.resolver
agentAuthentication = agentHostServices.authentication
agentConnections = agentHostServices.connections
agentBackgroundCommands = agentHostServices.background
hostRepository = dependencies.hostRepository
vaultSync = dependencies.vaultSync
connectionHistorySync = dependencies.connectionHistorySync
deviceSync = dependencies.deviceSync
settingsSync = dependencies.settingsSync
metricsSync = dependencies.metricsSync
forwarding = PortForwardingController(
hosts: hosts, poolPolicy: .hostConfigured,
connectionLimit: MultiConnectionPolicy(
allowsMultipleConnections: proAccess.canUse(.portForwardingMultiConnection)
).forwardingConnections(Host.portForwardingMaxConnectionsRange.upperBound),
logger: dependencies.logging.makeLogger(label: "termind.port-forwarding"))
sftpTransfers = SFTPTransferCoordinator(
store: hosts,
clientFactory: { connectionCoordinator.makeSFTPClient() },
globalLimit: sftpPreferences.globalLimit,
perHostLimit: sftpPreferences.perHostLimit,
strategy: sftpPreferences.strategy,
concurrencyScheduler: sftpConcurrencyScheduler
)
sftpEdits = SFTPEditService(transfers: sftpTransfers, host: { [weak hosts] in hosts?.host($0) })
self.ai = ai
self.skills = skills
self.memory = memory
self.terminalHighlightSettings = terminalHighlightSettings
self.terminalSettings = terminalSettings
self.notificationSettings = notificationSettings
self.sshSettings = sshSettings
self.sftpSettings = sftpSettings
self.workspaceSettings = workspaceSettings
commandGenerator = CommandGeneratorController(ai: ai)
conversationTitles = ConversationTitleController(ai: ai)
self.speechTranscription =
speechTranscription
?? SpeechTranscriptionCoordinator(
service: AppleSpeechTranscriptionService(audioInput: MacOSSpeechAudioInput())
)
outboxRunner = dependencies.outboxRunner
mcpServer = MCPServerController(
secrets: dependencies.mcpServerSecrets, hosts: agentHosts, metrics: serverMetrics,
connections: agentConnections,
authentication: agentAuthentication, background: agentBackgroundCommands,
externalAuthentication: externalAuthentication, history: externalToolCalls,
canUsePro: { proAccess.canUse(.mcpServer) },
requestPro: { proAccess.require(.mcpServer) }
)
agentHosts.configureTemporaryTargets(
targets: { [weak self] in self?.temporaryAgentTargets() ?? [] },
configuration: { [weak self] hostID in
self?.temporaryAgentConfiguration(for: hostID)
}
)
hosts.willDeleteHost = {
[weak forwarding, weak mcpServer, agentConnections, weak sftpEdits] hostID, ruleIDs in
sftpEdits?.invalidate(hostID: hostID)
mcpServer?.tools.revokeHost(hostID)
forwarding?.hostConfigurationChanged(hostID)
forwarding?.stop(ruleIDs)
// Not a `HostDeletionOutbox` step like the others: a live socket
// cannot outlive the process, so there is nothing to replay after a
// crash and nothing to make durable.
Task { await agentConnections.disconnect(hostID: hostID) }
}
hosts.willDeletePortForwardings = { [weak forwarding] ruleIDs in
forwarding?.stop(ruleIDs)
}
hosts.didChangeHostConnection = { [weak forwarding, weak self] hostID in
forwarding?.hostConfigurationChanged(hostID)
guard let self else { return }
let message =
self.hosts.host(hostID)?.sshEnabled == true
? String(localized: AppStrings.Connections.theHostConfigurationChangedReconnectToApplyIt)
: String(localized: AppStrings.Connections.sshIsDisabledForThisHost)
self.disconnectWorkspaces(for: hostID, protocol: .ssh, message: message)
}
hosts.didChangeTelnetConnection = { [weak self] hostID in
guard let self else { return }
let message =
self.hosts.host(hostID)?.telnetEnabled == true
? String(localized: AppStrings.Connections.theHostConfigurationChangedReconnectToApplyIt)
: String(localized: AppStrings.Connections.telnetIsDisabledForThisHost)
self.disconnectWorkspaces(for: hostID, protocol: .telnet, message: message)
}
hosts.didPersistCore = { [weak repository = dependencies.hostRepository] in
try? repository?.refresh()
}
hosts.didRecordVaultChanges = { [weak vaultSync = dependencies.vaultSync] mutations in
vaultSync?.noteLocalChanges(mutations)
}
if let repository = dependencies.hostRepository as? SwiftDataHostRepository {
repository.didRecordHistoryChanges = { [weak connectionHistorySync = dependencies.connectionHistorySync] in
connectionHistorySync?.noteLocalChanges($0)
}
repository.didRecordVaultChanges = { [weak vaultSync = dependencies.vaultSync] mutations in
vaultSync?.noteLocalChanges(mutations)
}
}
if let outboxRunner {
outboxRunner.registerHostDeletionHandlers(
repository: hostRepository,
closeWorkspaces: { [weak self] deletion in
self?.serverMetrics.remove(deletion.hostID)
self?.closeWorkspaces(for: deletion.hostID)
self?.connectionHistory.clear(for: deletion.hostID)
},
cancelTransfers: { [weak self] deletion in
self?.sftpEdits.invalidate(hostID: deletion.hostID)
self?.sftpTransfers.cancelAll(for: deletion.hostID)
self?.externalAuthentication.cancel(hostID: deletion.hostID)
self?.mcpServer.tools.revokeHost(deletion.hostID)
if let self { Task { await self.mcpServer.tools.cancelHostTransfers(deletion.hostID) } }
},
stopForwardings: { [weak self] deletion in
self?.forwarding.hostConfigurationChanged(deletion.hostID)
self?.forwarding.remove(deletion.forwardingRuleIDs)
}
)
outboxRunner.start()
}
connectionHistorySync?.didApplyRemote = { [weak self] _ in self?.connectionHistory.reload() }
deviceSync?.didApplyRemote = { [weak self] _ in self?.devices.reload() }
vaultSync?.didApplyRemote = { [weak self] snapshot, keys in
self?.applyRemoteVaultSnapshot(snapshot, changedKeys: keys)
}
sftpSettings.didChange = { [weak self] _ in
self?.reconcileSFTPPreferences()
}
hosts.didDeleteTailscaleGroups = { [weak self] groups, hostIDs in
guard let self else { return }
self.serverMetrics.removeTailscaleGroups(groups)
for id in hostIDs {
self.closeWorkspaces(for: id)
self.sftpEdits.invalidate(hostID: id)
self.sftpTransfers.cancelAll(for: id)
self.serverMetrics.remove(id)
}
}
tailscale.start()
proAccess.accessDidChange = { [weak self] in
self?.tailscale.reconcile()
self?.serverMetrics.reconcile()
self?.reconcileConnectionPreferences()
}
sshSettings.didChange = { preferences in
connectionCoordinator.updateSSHPreferences(preferences)
}
notifications.onOpen = { [weak self] target in self?.notificationRouter.open(target) }
sftpNotifications = SFTPNotificationCoordinator(
service: notifications, initialTransfers: hosts.sftpTransfers,
preferences: { [weak self] in self?.notificationSettings.preferences ?? .init() },
isFocused: {
NSApp.isActive && NSApp.windows.contains { $0.isVisible && !$0.isMiniaturized && $0.canBecomeKey }
},
content: SFTPNotificationContent.make)
hosts.sftpTransferStateDidChange = { [weak self] in self?.sftpNotifications?.receive($0) }
hosts.sftpTransfersDidRemove = { [weak self] in self?.sftpNotifications?.forgetFinishedTransfers($0) }
notificationBadge = NotificationBadgeCoordinator(service: notifications) { [weak self] in
guard let self, self.notificationSettings.preferences.isEnabled else { return 0 }
return self.unreadNotificationCount
}
ai.didChange = { [weak self] in self?.repairUnavailableAssistantModels() }
settingsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.skills.applyRemoteSnapshot(snapshot.skills)
self?.terminalHighlightSettings.applyRemoteSnapshot(snapshot.terminalHighlighting)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
self?.sshSettings.applyRemoteSnapshot(snapshot.ssh)
self?.sftpSettings.applyRemoteSnapshot(snapshot.sftp)
self?.workspaceSettings.applyRemoteSnapshot(snapshot.workspace)
self?.notificationSettings.applyRemoteSnapshot(snapshot.notifications)
}
metricsSync?.didApplyRemote = { [weak serverMetrics] snapshot, _ in
serverMetrics?.applyRemoteSnapshot(snapshot)
}
if proAccess.canUse(.vaultSync) {
vaultSync?.start()
connectionHistorySync?.start()
deviceSync?.start()
metricsSync?.start()
settingsSync?.start()
}
_ = assistantChatCoordinator
}
private func reconcileConnectionPreferences() {
reconcileSFTPPreferences()
let forwardingPolicy = MultiConnectionPolicy(
allowsMultipleConnections: proAccess.canUse(.portForwardingMultiConnection))
forwarding.updateConnectionLimit(
forwardingPolicy.forwardingConnections(Host.portForwardingMaxConnectionsRange.upperBound))
}
private func reconcileSFTPPreferences() {
let policy = MultiConnectionPolicy(allowsMultipleConnections: proAccess.canUse(.sftpMultiConnection))
sftpTransfers.updatePreferences(policy.sftpPreferences(sftpSettings.preferences))
}
private func repairUnavailableAssistantModels() {
do {
try assistantModelSelections.repairUnavailable(in: assistantConversations.conversations)
} catch {
for window in windows.values.compactMap(\.value) {
window.assistantOperationError = error.localizedDescription
}
}
}
func reconcileProAccess() {
if proAccess.canUse(.vaultSync) {
vaultSync?.start()
connectionHistorySync?.start()
deviceSync?.start()
metricsSync?.start()
settingsSync?.start()
mcpServer.resumeForProAccess()
} else {
vaultSync?.pause()
connectionHistorySync?.pause()
deviceSync?.pause()
metricsSync?.pause()
settingsSync?.pause()
}
if !proAccess.canUse(.mcpServer), mcpServer.isEnabled {
mcpServer.suspendForProAccess()
}
}
private func applyRemoteVaultSnapshot(
_ snapshot: VaultSnapshot, changedKeys: Set<VaultRecordKey>
) {
let retainedHostIDs = Set(snapshot.hosts.map(\.id))
for key in changedKeys where key.kind == .host && !retainedHostIDs.contains(key.id) {
serverMetrics.remove(key.id)
}
let plan = VaultRemoteChangePlan.make(
currentHosts: hosts.hosts,
snapshot: snapshot,
changedKeys: changedKeys
)
for change in plan.hosts {
if change.routeChanged {
hosts.willDeleteHost?(change.id, hosts.portForwardings(for: change.id).map(\.id))
}
if change.isRemoved {
serverMetrics.remove(change.id)
closeWorkspaces(for: change.id)
sftpEdits.invalidate(hostID: change.id)
sftpTransfers.cancelAll(for: change.id)
externalAuthentication.cancel(hostID: change.id)
mcpServer.tools.revokeHost(change.id)
connectionHistory.clear(for: change.id)
}
}
if !plan.forwardingRuleIDs.isEmpty { forwarding.stop(plan.forwardingRuleIDs) }
hosts.replacePersistedSnapshot(snapshot)
forwarding.reconcileConfiguration()
}
@ObservationIgnored private lazy var assistantOperations = AssistantConversationOperations(
repository: agentRunRepository, modelSelections: assistantModelSelections)
@ObservationIgnored private lazy var assistantExecution = AssistantExecutionController(
ai: ai, skills: skills, repository: agentRunRepository, memory: memory,
modelSelections: assistantModelSelections,
reportModelSelectionError: { [weak self] message in
for window in self?.windows.values.compactMap(\.value) ?? [] {
window.assistantOperationError = message
}
},
observeNotifications: { [weak self] in self?.assistantNotifications.observe($0) },
authorize: { [weak self] in
guard let self, !self.isTerminating else { return false }
return self.proAccess.require(.assistant)
},
makeRegistry: { [weak self] conversation, model, provider, skills, channel, panes, source in
guard let self else { return AgentToolRegistry([AnyAgentTool]()) }
return AssistantToolRegistry.make(
conversation: conversation, model: model, provider: provider,
skills: skills, channel: channel, ai: ai, hosts: agentHosts, metrics: serverMetrics,
connections: agentConnections, authentication: agentAuthentication,
background: agentBackgroundCommands, panes: panes,
chatModels: assistantChatCoordinatorBox.value, chats: assistantChatCoordinatorBox.value,
chatSource: source)
},
title: { [weak self] context in
guard let self else { throw CancellationError() }
return try await conversationTitles.summarize(context)
},
logger: logging.makeLogger(label: "termind.agent"))
@ObservationIgnored private lazy var assistantChatCoordinator: AssistantChatCoordinator = {
let coordinator = AssistantChatCoordinator(
conversations: assistantConversations, ai: ai,
modelSelections: assistantModelSelections,
collaborationStore: assistantChatCollaborationStore,
prepareDelivery: { [weak self] text, origin, conversation in
self?.assistantExecution.prepareIncomingAgentMessage(
text, origin: origin, in: conversation)
},
persistDelivery: { [weak self] prepared, conversation in
guard let self else { throw CancellationError() }
try await self.assistantExecution.persistPreparedAgentDelivery(
prepared, in: conversation)
},
startDelivery: { [weak self] prepared, conversation in
self?.assistantNotifications.observe(conversation)
self?.assistantExecution.startPreparedAgentDelivery(prepared, in: conversation)
},
cancelDelivery: { [weak self] prepared, conversation in
await self?.assistantExecution.cancelPreparedAgentDelivery(
prepared, in: conversation)
})
assistantChatCoordinatorBox.value = coordinator
return coordinator
}()
func sendAgentDraft(
_ conversation: ConversationModel, context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false, window: WindowModel? = nil
) {
assistantExecution.sendAgentDraft(
conversation, context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable,
panes: paneAccess(for: window))
}
@discardableResult
func sendNewAgentConversation(
_ conversation: ConversationModel, in library: AssistantLibraryModel,
context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false,
window: WindowModel? = nil
) -> ConversationModel? {
assistantExecution.sendNewConversation(
conversation, in: library, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, panes: paneAccess(for: window))
}
@discardableResult
func sendNewAgentDraft(
in library: AssistantLibraryModel, context: SessionContext = .none,
terminalSuggestionsAvailable: Bool = false, window: WindowModel? = nil
) -> ConversationModel? {
assistantExecution.sendDraftConversation(
in: library, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, panes: paneAccess(for: window))
}
@discardableResult
func sendAgentText(
_ text: String, in conversation: ConversationModel,
context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false,
window: WindowModel? = nil
) -> Bool {
assistantExecution.sendAgentText(
text, in: conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, panes: paneAccess(for: window))
}
@discardableResult
func sendPlanImplementation(
_ card: AgentPlanCard, in conversation: ConversationModel,
context: SessionContext = .none, terminalSuggestionsAvailable: Bool = false,
window: WindowModel? = nil
) -> Bool {
assistantExecution.sendPlanImplementation(
card, in: conversation, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, panes: paneAccess(for: window))
}
func startAgentRun(
_ conversation: ConversationModel, promptID: UUID? = nil, prompt: String? = nil,
authoredPrompt: String? = nil, promptImages: [AgentImageAttachment]? = nil,
promptContextAttachments: [AgentContextAttachment]? = nil,
context: SessionContext? = nil, terminalSuggestionsAvailable: Bool = false,
window: WindowModel? = nil
) {
assistantExecution.startAgentRun(
conversation, promptID: promptID, prompt: prompt,
authoredPrompt: authoredPrompt, promptImages: promptImages,
promptContextAttachments: promptContextAttachments,
context: context, terminalSuggestionsAvailable: terminalSuggestionsAvailable,
panes: paneAccess(for: window))
}
func paneAccess(for source: WindowModel?) -> AgentPaneAccess {
AgentPaneAccess(
list: { [weak self, weak source] in
self?.openTerminalPanes(source: source)
.filter { $0.pane.status == .connected && $0.pane.readViewportText() != nil }
.map(\.metadata) ?? []
},
read: { [weak self, weak source] id in
guard let match = self?.openTerminalPanes(source: source).first(where: { $0.metadata.id == id })
else { return .closed }
guard match.pane.status == .connected else { return .notConnected }
let owner: PersistentTerminalViewOwner?
switch match.pane.content {
case .sshSession(let session): owner = session.terminalViewOwner
case .telnetSession(let session): owner = session.terminalViewOwner
case .serialSession(let session): owner = session.terminalViewOwner
case .sftpChannel, .empty: return .closed
}
guard let text = owner?.readViewportText() else { return .noSurface }
return .text(match.metadata, text)
})
}
private func openTerminalPanes(source: WindowModel?) -> [(metadata: AgentPaneAccess.Pane, pane: Pane)] {
windows.values.compactMap(\.value).filter { !$0.isClosed }.flatMap { window in
window.workspaces.flatMap { workspace in
workspace.panes.compactMap { pane in
guard pane.sshSession != nil || pane.telnetSession != nil || pane.serialSession != nil
else { return nil }
let isSelected = window.selectedWorkspace === workspace && workspace.focusedPaneID == pane.id
let isCurrent = window === source && isSelected
let metadata = AgentPaneAccess.Pane(
id: pane.id,
title: workspace.isSplit ? pane.title(in: hosts) : workspace.title(in: hosts),
hostID: pane.hostID,
workspace: workspace.title(in: hosts),
window: window.id.uuidString,
isActive: isSelected,
isCurrent: isCurrent
)
return (metadata, pane)
}
}
}
}
@discardableResult
func compact(_ conversation: ConversationModel, instructions: String = "", isManual: Bool = true) async -> Bool {
await assistantExecution.compact(conversation, instructions: instructions, isManual: isManual)
}
func persistAndApplyCompaction(_ record: AgentCompactionRecord, to conversation: ConversationModel, cut: Int)
async throws
{
try await assistantExecution.persistAndApplyCompaction(record, to: conversation, cut: cut)
}
static func titleContext(for conversation: ConversationModel) -> ConversationTitleContext? {
AssistantExecutionController.titleContext(for: conversation)
}
func unregisterWindow(_ window: WindowModel) {
windows[ObjectIdentifier(window)] = nil
onboarding.windowClosed(window)
}
func registerWindow(_ window: WindowModel) {
guard !window.isClosed, windows[ObjectIdentifier(window)]?.value !== window else { return }
configureConnectionHistory(in: window)
window.connectionCoordinator = connections
window.hostStore = hosts
window.metricsModel = serverMetrics
windows = windows.filter { $0.value.value != nil }
windows[ObjectIdentifier(window)] = WeakWindow(window)
window.assistantLibrary.adopt(assistantConversations)
window.assistantLibrary.modelSelections = assistantModelSelections
window.assistantCommands = AssistantConversationCommands(
delete: { [weak self] id in self?.deleteAgentConversation(id) },
togglePin: { [weak self, weak window] id in
self?.togglePin(id, reportingTo: window)
},
move: { [weak self, weak window] id, pinned, nextID in
self?.moveConversation(id, toPinned: pinned, before: nextID, reportingTo: window)
},
rename: { [weak self, weak window] id, title in
self?.renameConversation(id, to: title, reportingTo: window)
},
archive: { [weak self, weak window] id, archived in
self?.setArchived(id, archived: archived, reportingTo: window)
},
fork: { [weak self, weak window] id, messageID, inSidebar in
self?.forkConversation(id, at: messageID, selectingIn: window, inSidebar: inSidebar)
},
edit: { [weak self, weak window] id, messageID, text, context, terminalSuggestionsAvailable in
self?.editConversation(
id, messageID: messageID, text: text, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable, reportingTo: window
)
}
)
Task { [agentRunRepository, weak window, weak self] in
do {
let restored = try await agentRunRepository.loadConversations()
guard window?.isClosed == false else { return }
try self?.restoreAssistantConversations(restored)
self?.notificationRouter.resume()
} catch {
window?.assistantOperationError = error.localizedDescription
}
}
}
var notificationWindows: [WindowModel] { windows.values.compactMap(\.value).filter { !$0.isClosed } }
func restoreAssistantConversations() async throws {
try restoreAssistantConversations(await agentRunRepository.loadConversations())
}
private func restoreAssistantConversations(_ restored: [StoredAgentConversation]) throws {
guard !isTerminating else { return }
let visible = restored.filter { !deletedAgentConversationIDs.contains($0.id) }
let selections = try assistantModelSelections.selectionsForRestore(visible)
assistantConversations.restore(visible, modelSelections: selections)
}
private func deleteAgentConversation(_ id: UUID) {
guard let conversation = conversation(id) else { return }
Task {
do {
let result = try await assistantOperations.delete(conversation)
guard result.deleted else { return }
let wasArchived = conversation.isArchived
deletedAgentConversationIDs.insert(id)
windows = windows.filter { $0.value.value != nil }
for window in windows.values.compactMap(\.value) {
window.transcriptCache.removeConversation(id)
let wasSelected = window.assistantLibrary.selectedConversationID == id
window.assistantLibrary.delete(id)
if wasArchived, wasSelected {
window.presentAssistantArchive()
}
}
if let error = result.cleanupError {
for window in windows.values.compactMap(\.value) { window.assistantOperationError = error }
}
} catch {
for window in windows.values.compactMap(\.value) {
window.assistantOperationError = error.localizedDescription
}
}
}
}
private func conversation(_ id: UUID) -> ConversationModel? {
assistantConversations.conversation(id: id)
}
private func togglePin(_ id: UUID, reportingTo window: WindowModel?) {
guard let conversation = conversation(id), !conversation.isArchived,
let library = window?.assistantLibrary
else { return }
let toPinned = !conversation.isPinned
let nextID =
(toPinned
? library.pinnedConversations
: library.activeConversations).first?.id
moveConversation(
id, toPinned: toPinned, before: nextID, reportingTo: window
)
}
private func moveConversation(_ id: UUID, toPinned: Bool, before nextID: UUID?, reportingTo window: WindowModel?) {
guard let conversation = conversation(id), let library = window?.assistantLibrary else { return }
Task { [weak window] in
do {
try await assistantOperations.move(conversation, pinned: toPinned, before: nextID, library: library)
} catch { window?.assistantOperationError = error.localizedDescription }
}
}
private func renameConversation(_ id: UUID, to value: String, reportingTo window: WindowModel?) {
guard let conversation = conversation(id) else { return }
Task { [weak window] in
do { try await assistantOperations.rename(conversation, title: value) } catch {
window?.assistantOperationError = error.localizedDescription
}
}
}
private func setArchived(_ id: UUID, archived: Bool, reportingTo window: WindowModel?) {
guard let conversation = conversation(id) else { return }
Task { [weak window] in
do {
try await assistantOperations.setArchived(archived, conversation: conversation)
if archived && conversation.isArchived { moveOpenConversationSurfacesToNewChat(id) }
} catch { window?.assistantOperationError = error.localizedDescription }
}
}
private func moveOpenConversationSurfacesToNewChat(_ id: UUID) {
windows = windows.filter { $0.value.value != nil }
for window in windows.values.compactMap(\.value) {
window.assistantConversationWasArchived(id)
}
}
private func forkConversation(_ id: UUID, at messageID: UUID, selectingIn window: WindowModel?, inSidebar: Bool) {
guard let source = conversation(id) else { return }
let title = assistantConversations.nextForkTitle(for: source)
Task { [weak window] in
do {
guard let fork = try await assistantOperations.fork(source, at: messageID, title: title) else { return }
fork.attachmentLoader = .live
assistantConversations.insert(fork)
if inSidebar { window?.openAssistantInSidebar(fork.id) } else { window?.selectAssistant(fork.id) }
} catch { window?.assistantOperationError = error.localizedDescription }
}
}
private func editConversation(
_ id: UUID, messageID: UUID, text value: String,
context: SessionContext, terminalSuggestionsAvailable: Bool, reportingTo window: WindowModel?
) {
guard let conversation = conversation(id) else { return }
guard
let prepared = assistantExecution.prepareAgentEdit(
conversation, messageID: messageID, text: value, context: context,
terminalSuggestionsAvailable: terminalSuggestionsAvailable,
panes: paneAccess(for: window))
else { return }
Task { [weak window] in
do {
_ = try await assistantExecution.performAgentEdit(
prepared, in: conversation, operations: assistantOperations)
} catch { window?.assistantOperationError = error.localizedDescription }
}
}
@discardableResult
func requestHostDeletion(_ hostID: Host.ID) -> Bool {
guard hosts.host(hostID)?.isTailscaleManaged != true else { return false }
do {
try hostRepository.requestHostDeletion(hostID)
serverMetrics.remove(hostID)
forwarding.hostConfigurationChanged(hostID)
forwarding.remove(hosts.portForwardings(for: hostID).map(\.id))
connectionHistory.clear(for: hostID)
hosts.projectHostDeletion(hostID)
return true
} catch {
hosts.reportStorageError(error)
return false
}
}
func deleteHostGroup(_ groupID: HostGroup.ID, keepHosts: Bool) {
if !keepHosts {
let affected = hosts.hosts(inTreeOf: groupID).filter { !$0.isTailscaleManaged }
guard affected.allSatisfy({ requestHostDeletion($0.id) }) else { return }
}
hosts.deleteGroupProjection(groupID)
}
private func closeWorkspaces(for hostID: Host.ID) {
windows = windows.filter { $0.value.value != nil }
for window in windows.values.compactMap(\.value) {
window.closeWorkspaces(for: hostID)
}
}
private func disconnectWorkspaces(
for hostID: Host.ID, protocol connectionProtocol: HostTerminalProtocol, message: String
) {
windows = windows.filter { $0.value.value != nil }
for window in windows.values.compactMap(\.value) {
window.disconnectSessions(for: hostID, protocol: connectionProtocol, message: message)
}
}
private func temporaryAgentTargets() -> [AgentHostTarget] {
windows = windows.filter { $0.value.value != nil }
var seen = Set<UUID>()
return windows.values.compactMap(\.value).flatMap(\.workspaces).flatMap(\.panes)
.compactMap { pane in
pane.sshSession?.agentTemporaryTarget ?? pane.telnetSession?.agentTemporaryTarget
}
.filter { seen.insert($0.id).inserted }
}
private func temporaryAgentConfiguration(
for hostID: UUID
) -> SSHConnectionConfiguration? {
windows = windows.filter { $0.value.value != nil }
return windows.values.compactMap(\.value).flatMap(\.workspaces).flatMap(\.panes)
.compactMap(\.sshSession)
.first { $0.hostID == hostID && $0.agentTemporaryTarget != nil }?
.agentConnectionConfiguration
}
/// The delegate gives this asynchronous cleanup a bounded termination window.
@discardableResult
func shutdownForTermination() -> Task<Void, Never> {
isTerminating = true
notificationBadge?.stop()
sftpNotifications?.stop()
assistantNotifications.stop()
terminalNotifications.stop()
notificationRouter.stop()
NSApp.dockTile.badgeLabel = nil
if let terminationTask { return terminationTask }
let conversations = assistantConversations.conversations
let preparationCancellation = assistantExecution.cancelPendingRuns()
for conversation in conversations { conversation.stop() }
let conversationDrains = conversations.map { conversation in
Task { @MainActor in await conversation.stopAndWait() }
}
let task = Task {
[shutdownDependencies, mcpServer, sftpTransfers, agentBackgroundCommands, agentConnections, tailscale] in
async let stopMCP: Void = {
if let stop = shutdownDependencies?.stopMCP {
await stop()
} else {
await mcpServer.stop()
}
}()
async let stopSFTP: Void = {
if let stop = shutdownDependencies?.stopSFTP {
await stop()
} else {
await sftpTransfers.waitForCancellation()
}
}()
await preparationCancellation.value
for drain in conversationDrains { await drain.value }
_ = await (stopMCP, stopSFTP)
if let close = shutdownDependencies?.closeAgentBackgroundCommands {
await close()
} else {
await agentBackgroundCommands.closeAll()
}
if let close = shutdownDependencies?.closeAgentConnections {
await close()
} else {
await agentConnections.closeAll()
}
await tailscale.shutdown()
}
terminationTask = task
memory.suspend()
sftpEdits.setMonitoring(false)
serverMetrics.shutdown()
speechTranscription.cancelAll()
let liveWindows = windows.values.compactMap(\.value)
windows.removeAll()
for window in liveWindows { window.closeAll() }
sftpTransfers.cancelAll()
externalAuthentication.cancelAll()
forwarding.stopAll()
outboxRunner?.stop()
return task
}
/// Opens a host in the given window, handing back the workspace it landed in.
///
/// The initial status flip is optimistic; once the terminal appears, its SSH
/// session reports the authoritative state back to `HostLibraryModel`.
///
/// The return value exists for `open(_:as:in:)`'s plural form, which has to
/// know which workspace was first. Discardable because nearly every caller opens
/// one host and has nothing to decide afterwards.
@discardableResult
func open(
_ host: Host,
as target: HostOpenTarget,
in window: WindowModel
) -> Workspace {
configureConnectionHistory(in: window)
beginOpening(host, as: target)
switch target {
case .preferredTerminal:
switch host.preferredTerminalProtocol {
case .ssh: return window.openSSHSession(host: host)
case .telnet: return window.openTelnetSession(host: host)
case nil: return window.openTelnetSession(host: host)
}
case .sshSession: return window.openSSHSession(host: host)
case .telnetSession: return window.openTelnetSession(host: host)
case .sftpChannel: return window.openSFTPChannel(host: host)
}
}
@discardableResult
func openQuickConnect(_ target: QuickConnectTarget, in window: WindowModel) -> Workspace {
if target.isSaved {
return open(
target.host,
as: target.connectionProtocol == .ssh ? .sshSession : .telnetSession,
in: window)
}
hosts.setStatus(.connecting, for: target.host.id)
if target.connectionProtocol == .telnet {
return window.openTelnetSession(host: target.host)
}
return window.openSSHSession(
host: target.host, usernameHint: target.username, connectionRoute: target.route
)
}
@discardableResult
func saveQuickConnect(_ target: QuickConnectTarget) throws -> Host {
if target.connectionProtocol == .telnet {
var host = target.host
host.sshEnabled = false
host.telnetEnabled = true
guard hosts.saveHost(host) else {
throw TermindSSHError.library(
hosts.storageError ?? String(localized: AppStrings.Vault.theHostCouldNotBeSaved))
}
return hosts.host(host.id) ?? host
}
let route = try hosts.saveConnectionRoute(target.route)
guard let host = route.hosts.last else { throw SSHConnectionRoute.invalidRouteError }
return host
}
func resolveQuickConnect(_ input: QuickConnectInput, locale: Locale = .current) throws -> QuickConnectTarget {
try QuickConnectResolver.resolve(input, in: hosts, locale: locale)
}
func openExternalConnection(_ url: URL, in window: WindowModel) {
do {
let request = try RemoteConnectionURLParser.parse(url)
if request.kind == .telnet, request.advisoryPassword != nil {
window.pendingExternalConnection = request
} else {
openExternalConnection(request, in: window)
}
} catch {
window.externalConnectionError = error.localizedDescription
}
}
func confirmExternalConnection(in window: WindowModel) {
guard let request = window.pendingExternalConnection else { return }
window.pendingExternalConnection = nil
openExternalConnection(request, in: window)
}
private func openExternalConnection(_ request: RemoteConnectionURL, in window: WindowModel) {
switch request.kind {
case .telnet:
let host = Host(
name: request.host, host: request.host,
sshEnabled: false, telnetEnabled: true, telnetPort: request.port)
window.openExternalTelnetSession(
host: host, username: request.username,
password: request.advisoryPassword.map(SSHSecret.init))
case .ssh, .sftp:
let host = Host(name: request.host, host: request.host, port: request.port)
let route = SSHConnectionRoute(
hosts: [host], usernames: request.username.map { [host.id: $0] } ?? [:])
if request.kind == .ssh {
let workspace = window.openSSHSession(
host: host, usernameHint: request.username, connectionRoute: route)
workspace.focusedPane.sshSession?.connectionOrigin = .externalLink(
hostKeyConstraints: request.hostKeyFingerprint.map { [host.id: $0] } ?? [:])
return
}
let workspace = window.openSFTPChannel(host: host)
guard let channel = workspace.focusedPane.sftpChannel else { return }
channel.configureExternalConnection(
host: host, username: request.username, route: route,
fingerprint: request.hostKeyFingerprint,
resourcePath: request.path,
mustBeDirectory: request.sftpType == .directory
) { [weak self, weak channel] file in
guard let self, let channel else { return }
channel.revealExternalFile(file)
guard Self.canAutomaticallyOpenExternalSFTPFile(file) else {
channel.operationError = String(
localized: AppStrings.SFTP.executableFileWarning)
return
}
channel.open(file, coordinator: self.sftpTransfers, edits: self.sftpEdits) {
[history = channel.history] localURL in
if NSWorkspace.shared.open(localURL) {
history.record(kind: .file, path: file.path)
}
}
}
}
}
private static func canAutomaticallyOpenExternalSFTPFile(_ file: RemoteFile) -> Bool {
guard file.kind == .file, file.permissions & 0o111 == 0 else { return false }
let unsafeExtensions: Set<String> = [
"app", "bash", "bin", "command", "dmg", "exe", "ksh", "mpkg", "pkg", "pl", "py", "rb",
"sh", "tool", "zsh",
]
return !unsafeExtensions.contains((file.name as NSString).pathExtension.lowercased())
}
/// Optimistically shows connecting until the session reports its own state.
///
/// Its own method because three code paths now open hosts — one, a batch of
/// workspaces, and a batch into one Workspace — and *where it lands* is the only
/// thing that should differ between them.
private func beginOpening(_ host: Host, as target: HostOpenTarget) {
switch target {
case .preferredTerminal, .sshSession, .telnetSession:
hosts.setStatus(.connecting, for: host.id)
case .sftpChannel:
break
}
}
/// Clears every user-facing history owned by the selected saved Hosts.
/// Host records and credentials remain intact.
func clearAllHistory(for hostIDs: [Host.ID]) {
for hostID in Set(hostIDs) {
connectionHistory.clear(for: hostID)
}
}
/// Clears the global connection timeline and the Recents projection it
/// supersedes in the History sidebar.
func clearConnectionHistory() {
connectionHistory.clearAll()
}
func lastConnectedAt(for hostID: Host.ID) -> Date? {
connectionHistory.mostRecentDate(for: hostID)
}
func lastConnectedLocallyAt(for hostID: Host.ID) -> Date? {
connectionHistory.mostRecentLocalDate(for: hostID)
}
/// Opens multiple Hosts, with the layout chosen by the calling surface.
///
/// The workspaces path is a loop over the singular `open` rather than its own code
/// path; the Workspace path cannot be, since the whole point is that it makes
/// one workspace. What both share is `beginOpening`, so the bookkeeping cannot
/// drift between them.
///
/// In the workspaces case each `open` selects the workspace it made, which would leave
/// the *last* host in front — the one furthest from where the eye started.
/// Landing on the first instead matches the order the list was read and
/// ticked in, and the rest are a ⇧⌘] away.
func open(
_ hosts: [Host],
as target: HostOpenTarget,
layout: BatchLayout = .separateWorkspaces,
in window: WindowModel
) {
configureConnectionHistory(in: window)
switch layout {
case .separateWorkspaces:
let opened = hosts.map { open($0, as: target, in: window) }
if let first = opened.first { window.select(first.id) }
case .oneWorkspace:
for host in hosts { beginOpening(host, as: target) }
window.openWorkspace(hosts, as: target)
}
}
/// Turns Snippet execution into ordinary visible sessions rather than a
/// hidden job. `overrideHostIDs` powers explicit host overrides; `fallbackHostIDs`
/// is used only by snippets whose saved target list is empty.
func execute(
_ snippets: [Snippet], overrideHostIDs: [Host.ID]? = nil,
fallbackHostIDs: [Host.ID]? = nil, in window: WindowModel
) {
configureConnectionHistory(in: window)
var opened: [Workspace] = []
for snippet in snippets {
let ids =
overrideHostIDs
?? (snippet.targetHostIDs.isEmpty
? (fallbackHostIDs ?? []) : snippet.targetHostIDs)
let wanted = Set(ids)
let targets = hosts.hosts(matching: "", for: .snippetExecution)
.filter { wanted.contains($0.id) }
guard !targets.isEmpty else { continue }
for host in targets { beginOpening(host, as: .sshSession) }
if let workspace = window.openSnippetWorkspace(
hosts: targets, command: snippet.body
) {
opened.append(workspace)
}
}
if let first = opened.first { window.select(first.id) }
}
func isAssistantNotificationFocused(_ id: UUID) -> Bool {
windows.values.compactMap(\.value).contains {
!$0.isClosed && ($0.selectedDestination == .assistant || $0.isShowingAssistant(id))
}
}
private func isAssistantConversationVisible(_ id: UUID) -> Bool {
windows.values.compactMap(\.value).contains { $0.isShowingAssistant(id) }
}
var unreadAssistantNotificationCount: Int {
assistantConversations.conversations.filter { !$0.isArchived }.reduce(0) { $0 + $1.unreadNotificationCount }
}
var unreadNotificationCount: Int {
unreadAssistantNotificationCount
+ windows.values.compactMap(\.value).filter { !$0.isClosed }
.reduce(0) { $0 + $1.unreadCompletionCount }
}
func refreshAssistantReadState() {
for conversation in assistantConversations.conversations where isAssistantConversationVisible(conversation.id) {
conversation.markNotificationsRead()
}
}
private func isTerminalNotificationFocused(_ id: UUID) -> Bool {
isCommandWorkspaceFocused(id, windowIsFocused: mainWindows.isFocused)
}
@discardableResult
func selectAssistantNotificationTarget(
_ id: UUID, windowIsFocused: ((WindowModel) -> Bool)? = nil
) -> WindowModel? {
let isFocused = windowIsFocused ?? mainWindows.isFocused
let available = windows.values.compactMap(\.value).filter { !$0.isClosed }
.sorted { $0.id.uuidString < $1.id.uuidString }
// A hidden Assistant route still belongs to its window. Reuse it before
// falling back to a window that has no selection for this conversation.
guard
let window = available.first(where: { $0.isShowingAssistant(id) && isFocused($0) })
?? available.first(where: { $0.isShowingAssistant(id) })
?? available.first(where: { $0.assistantLibrary.selectedConversationID == id && isFocused($0) })
?? available.first(where: { $0.assistantLibrary.selectedConversationID == id })
?? mainWindows.mostRecentWindow ?? available.first
else { return nil }
window.isToolbarFindFocused = false
window.isQuickOpenPresented = false
if conversation(id) != nil {
if window.selectedWorkspace != nil {
window.showAssistant()
window.openAssistantInSidebar(id)
} else if !window.isShowingAssistant(id) {
window.openAssistantFullScreen(id)
}
} else {
window.openAssistantFullScreen()
}
return window
}
func commandNotificationTarget(_ id: UUID) -> (WindowModel, Workspace, Pane)? {
for window in windows.values.compactMap(\.value) where !window.isClosed {
for workspace in window.workspaces {
if let pane = workspace.panes.first(where: { $0.commandSessionID == id }) {
return (window, workspace, pane)
}
}
}
return nil
}
func isCommandWorkspaceFocused(_ id: UUID, windowIsFocused: (WindowModel) -> Bool) -> Bool {
guard let (window, workspace, _) = commandNotificationTarget(id) else { return false }
return window.selectedWorkspace === workspace && windowIsFocused(window)
}
private func configureCommandMonitor(_ pane: Pane) {
guard let monitor = pane.commandMonitor, let id = pane.commandSessionID else { return }
terminalNotifications.observe(monitor, id: id)
}
@discardableResult
func selectCommandNotificationTarget(_ id: UUID) -> WindowModel? {
guard let (window, workspace, pane) = commandNotificationTarget(id) else { return nil }
window.isToolbarFindFocused = false
window.isQuickOpenPresented = false
window.select(workspace.id)
workspace.focusedPaneID = pane.id
return window
}
func openCommandNotification(_ id: UUID) {
guard let window = selectCommandNotificationTarget(id) else {
_ = mainWindows.openExisting()
NSApp.activate(ignoringOtherApps: true)
return
}
mainWindows.activate(window)
if let pane = commandNotificationTarget(id)?.2 {
pane.sshSession?.requestTerminalFocus()
pane.telnetSession?.requestTerminalFocus()
pane.serialSession?.requestTerminalFocus()
}
}
private func configureCommandNotifications(in window: WindowModel) {
window.configureCommandNotifications = { [weak self] pane in self?.configureCommandMonitor(pane) }
window.commandFocusChanged = { [weak self, weak window] in
guard let self, let window, self.mainWindows.isFocused(window) else { return }
window.selectedWorkspace?.markCommandsRead()
}
window.workspaces.flatMap(\.panes).forEach(configureCommandMonitor)
}
private func configureConnectionHistory(in window: WindowModel) {
configureCommandNotifications(in: window)
window.hostStore = hosts
window.workspacePreferences = { [weak self] in
self?.workspaceSettings.preferences ?? WorkspacePreferences()
}
window.recordConnectionHistory = { [weak self] hostID, kind in
guard let self, self.hosts.host(hostID) != nil else { return nil }
return self.connectionHistory.record(kind: kind, for: hostID)?.id
}
window.endConnectionHistory = { [weak self] id in
self?.connectionHistory.end(id)
}
}
/// ⌘T, the window toolbar's `+`, the workspace bar's `+`, and Welcome. Raises the
/// host chooser.
///
/// Termind has no local shell, so "new session" has to name a machine. A sheet
/// rather than the Hosts page: the page is a manager — it edits, groups, and
/// deletes — and sending every ⌘T there meant navigating away from whatever
/// was on screen just to ask one question.
///
/// The sheet starts with nothing selected, deliberately. A command that
/// connects to a selection made earlier, on another screen, is how a
/// keystroke meant for staging lands on production. ⌘T never connects on its
/// own; it puts the question up, and Return answers it.
func connect(in window: WindowModel) {
window.hostChooser = .connect
}
/// The Batch Startup row at the foot of the sidebar's Sessions section. The
/// same sheet again, in its multi-select
/// mode — see `HostChooserPurpose`.
///
/// The sidebar, Welcome page and File menu raise this chooser. Confirmation
/// opens the selected Hosts as panes of one new Workspace.
func batchStartup(in window: WindowModel) {
window.hostChooser = .batchStartup
}
}