From 3df3bcaf0ce6505be01ce963536345c0d35d8457 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sun, 13 Sep 2026 01:30:00 +0800 Subject: [PATCH] Refactor --- .../RegistrationAndLayoutTests.swift | 124 +++++ .../Agent/ChatSlashCommand.swift | 14 +- .../Agent/ChatSlashCommandRegistry.swift | 499 ++++++++++++++++++ .../Connections/VaultRemoteChangePlan.swift | 49 ++ .../Events/HostDeletionHandlers.swift | 37 ++ .../FeatureModels/LibrarySectionLayout.swift | 136 +++++ .../TermindDomain/AI/AIModelEditing.swift | 37 ++ .../Connections/ConnectionHistoryEntry.swift | 2 +- .../Snippets/SnippetEditing.swift | 14 + .../CommandHistoryPresentation.swift | 24 + .../SFTP/SFTPItemType.swift | 5 + .../VaultSyncStatusLabel.swift | 40 ++ .../Agent/AgentHostServices.swift | 42 ++ Memory/ARCHITECTURE.md | 21 + Memory/REFACTOR.md | 58 +- TFI/App/MobileAppDependencies.swift | 10 +- TFI/App/MobileAppModel.swift | 80 +-- TFI/App/MobileFeatureRegistry.swift | 166 ++++++ TFI/App/MobileRootView.swift | 154 +----- TFI/App/MobileSceneModel.swift | 18 +- .../Agent/MobileAssistantModel.swift | 39 +- .../FeatureModels/MobileHostPickerModel.swift | 34 ++ .../MobileSnippetOperations.swift | 23 + .../Metrics/MobileMetricsModel.swift | 31 +- .../Metrics/MobileMetricsServices.swift | 9 + .../MobileWorkspaceDescriptor.swift | 10 +- TFI/Features/AI/MobileAssistantComposer.swift | 216 +------- .../HostChooser/MobileConnectionPicker.swift | 35 +- TFI/Features/SFTP/MobileSFTPItemType.swift | 5 - TFI/Features/Settings/MobileModelFields.swift | 20 +- .../Settings/MobileSyncSettingsView.swift | 22 +- TFI/Features/Shell/MobileNavigation.swift | 26 - .../Snippets/MobileSnippetsView.swift | 82 +-- .../Terminal/MobileCommandHistoryView.swift | 3 +- .../MobileTerminalListPresentation.swift | 22 - TFI/Features/Vault/MobileVaultView.swift | 63 +-- .../Workspaces/MobileWorkspaceListView.swift | 111 ++++ .../SSH/MobileMetricsServices+Live.swift | 7 + TFITests/MobileAISettingsToolbarTests.swift | 4 +- TFITests/MobileAppTests.swift | 12 +- TFITests/MobileAssistantTests.swift | 2 +- TFITests/MobileCardTests.swift | 6 +- TFITests/MobileConnectionPickerTests.swift | 2 +- TFITests/MobileDropTests.swift | 5 +- TFITests/MobileNavigationParityTests.swift | 53 +- TFITests/MobileSnippetTests.swift | 2 +- TFM/App/AppModel.swift | 87 +-- TFM/Features/AI/ConversationViews.swift | 294 +---------- TFM/Features/Hosts/HostsPageView.swift | 93 +--- TFM/Features/SFTP/SFTPItemType.swift | 5 - TFM/Features/Settings/ModelListEditor.swift | 40 +- TFM/Features/Settings/SyncSettings.swift | 42 +- TFM/Features/Sidebar/WindowSidebarView.swift | 25 +- TFM/Features/Snippets/SnippetsPageView.swift | 74 +-- TFMTests/ArchitectureBoundaryTests.swift | 32 ++ TFMTests/ChatSlashCommandTests.swift | 8 +- TFMTests/KeychainOrderTests.swift | 2 +- 57 files changed, 1761 insertions(+), 1315 deletions(-) create mode 100644 FrameworkTests/RegistrationAndLayoutTests.swift create mode 100644 Frameworks/TermindApplication/Agent/ChatSlashCommandRegistry.swift create mode 100644 Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift create mode 100644 Frameworks/TermindApplication/FeatureModels/LibrarySectionLayout.swift create mode 100644 Frameworks/TermindDomain/AI/AIModelEditing.swift create mode 100644 Frameworks/TermindDomain/Snippets/SnippetEditing.swift create mode 100644 Frameworks/TermindPresentation/CommandHistoryPresentation.swift create mode 100644 Frameworks/TermindPresentation/SFTP/SFTPItemType.swift create mode 100644 Frameworks/TermindPresentation/VaultSyncStatusLabel.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift create mode 100644 TFI/App/MobileFeatureRegistry.swift create mode 100644 TFI/Application/FeatureModels/MobileHostPickerModel.swift create mode 100644 TFI/Application/FeatureModels/MobileSnippetOperations.swift create mode 100644 TFI/Application/Metrics/MobileMetricsServices.swift rename TFI/{Features => Application}/Workspaces/MobileWorkspaceDescriptor.swift (63%) delete mode 100644 TFI/Features/SFTP/MobileSFTPItemType.swift create mode 100644 TFI/Features/Workspaces/MobileWorkspaceListView.swift create mode 100644 TFI/Infrastructure/SSH/MobileMetricsServices+Live.swift delete mode 100644 TFM/Features/SFTP/SFTPItemType.swift diff --git a/FrameworkTests/RegistrationAndLayoutTests.swift b/FrameworkTests/RegistrationAndLayoutTests.swift new file mode 100644 index 00000000..a0b5a6b8 --- /dev/null +++ b/FrameworkTests/RegistrationAndLayoutTests.swift @@ -0,0 +1,124 @@ +import AgentKit +import Foundation +import TermindApplication +import TermindDomain +import Testing + +import struct TermindDomain.Host + +@Suite @MainActor +struct RegistrationAndLayoutTests { + @Test + func slashRegistryFiltersExplicitPlatforms() { + let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Inspect")]) + let configuration = AIConfigurationModel() + let skills = SkillLibraryModel() + let registry = ChatSlashCommandRegistry.standard() + let desktop = context( + platform: .desktop, + conversation: conversation, + configuration: configuration, + skills: skills, + archive: {}, + newConversation: {} + ) + let mobile = context( + platform: .mobile, + conversation: conversation, + configuration: configuration, + skills: skills, + archive: {}, + newConversation: {} + ) + + #expect(Set(registry.commands(in: desktop).map(\.id)) == ["archive", "compact", "new", "plan"]) + #expect(Set(registry.commands(in: mobile).map(\.id)) == ["compact", "plan"]) + #expect(registry.commands(in: mobile).allSatisfy { $0.platforms.contains(.mobile) }) + } + + @Test + func planRegistrationOwnsExecutionAlongsideItsDescriptor() { + let conversation = ConversationModel() + conversation.composer.draft = "/plan inspect disk" + var sends = 0 + let context = ChatSlashCommandContext( + platform: .mobile, + conversation: conversation, + configuration: AIConfigurationModel(), + skills: SkillLibraryModel(), + compact: { _ in }, + send: { sends += 1 } + ) + + #expect(ChatSlashCommandRegistry.standard().perform(draft: conversation.composer.draft, in: context)) + #expect(conversation.isPlanning) + #expect(conversation.composer.draft == "inspect disk") + #expect(sends == 1) + } + + @Test + func librarySectionLayoutsFlattenExpandedTreesOnce() { + let library = HostLibraryModel() + let parent = HostGroup(name: "Production") + let child = HostGroup(name: "Edge", parentID: parent.id) + let host = Host(name: "Web", host: "web.invalid", groupID: child.id) + library.upsert(parent) + library.upsert(child) + library.upsert(host) + + let sections = HostLibrarySectionLayout.sections(library: library) + #expect(sections.map(\.title).prefix(2) == ["Production", "Production / Edge"]) + #expect(sections.first { $0.id == .group(child.id) }?.hosts.map(\.id) == [host.id]) + + let snippets = SnippetLibraryModel(library: library) + let package = SnippetPackage(name: "Deploy") + snippets.upsert(package) + snippets.upsert(Snippet(title: "Restart", body: "systemctl restart app", packageID: package.id)) + #expect(SnippetLibrarySectionLayout.sections(library: snippets).first?.title == "Deploy") + } + + @Test + func vaultRemoteChangePlanDetectsRouteChangesAndRemovals() { + let changed = Host(name: "Changed", host: "old.invalid") + let removed = Host(name: "Removed", host: "removed.invalid") + var updated = changed + updated.host = "new.invalid" + let forwardingID = UUID() + let keys: Set = [ + VaultRecordKey(kind: .host, id: changed.id), + VaultRecordKey(kind: .host, id: removed.id), + VaultRecordKey(kind: .portForwarding, id: forwardingID), + ] + + let plan = VaultRemoteChangePlan.make( + currentHosts: [changed, removed], + snapshot: VaultSnapshot(hosts: [updated]), + changedKeys: keys + ) + + #expect(plan.hosts.first { $0.id == changed.id }?.routeChanged == true) + #expect(plan.hosts.first { $0.id == changed.id }?.isRemoved == false) + #expect(plan.hosts.first { $0.id == removed.id }?.isRemoved == true) + #expect(plan.forwardingRuleIDs == [forwardingID]) + } + + private func context( + platform: ChatSlashCommand.Platform, + conversation: ConversationModel, + configuration: AIConfigurationModel, + skills: SkillLibraryModel, + archive: (() -> Void)? = nil, + newConversation: (() -> Void)? = nil + ) -> ChatSlashCommandContext { + ChatSlashCommandContext( + platform: platform, + conversation: conversation, + configuration: configuration, + skills: skills, + archive: archive, + newConversation: newConversation, + compact: { _ in }, + send: {} + ) + } +} diff --git a/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift b/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift index dc36cc2a..1b64474e 100644 --- a/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift +++ b/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift @@ -7,6 +7,11 @@ import Foundation /// `AppCommandTarget`, and they route a *window*. These change the posture of /// one conversation, are typed into its draft, and never leave it. nonisolated public struct ChatSlashCommand: Identifiable, Hashable, Sendable { + public nonisolated enum Platform: String, CaseIterable, Hashable, Sendable { + case desktop + case mobile + } + public nonisolated enum Icon: Hashable, Sendable { case symbol(String) case contextRing @@ -35,9 +40,8 @@ nonisolated public struct ChatSlashCommand: Identifiable, Hashable, Sendable { public var displayName: String public var summary: String public var icon: Icon - /// Whether touch-first Assistant surfaces should include this command in - /// their Slash catalog. Desktop remains the complete command surface. - public var isShownOnMobile: Bool + /// Platforms whose composers offer and execute this command. + public var platforms: [Platform] /// Shown after the name when the command takes free text. Empty for a /// command that takes none. public var argumentHint: String = "" @@ -57,14 +61,14 @@ nonisolated public struct ChatSlashCommand: Identifiable, Hashable, Sendable { public init( id: String, displayName: String, summary: String, icon: Icon, - argumentHint: String = "", isShownOnMobile: Bool = true + argumentHint: String = "", platforms: [Platform] = Platform.allCases ) { self.id = id self.displayName = displayName self.summary = summary self.icon = icon self.argumentHint = argumentHint - self.isShownOnMobile = isShownOnMobile + self.platforms = platforms } } diff --git a/Frameworks/TermindApplication/Agent/ChatSlashCommandRegistry.swift b/Frameworks/TermindApplication/Agent/ChatSlashCommandRegistry.swift new file mode 100644 index 00000000..1eff57b5 --- /dev/null +++ b/Frameworks/TermindApplication/Agent/ChatSlashCommandRegistry.swift @@ -0,0 +1,499 @@ +import AgentKit +import Foundation + +/// Live state and host actions used by registered slash commands. +/// +/// Conversation mutations are shared. Window navigation and run orchestration +/// remain injected because their owners differ between macOS and iOS. +@MainActor +public struct ChatSlashCommandContext { + public let platform: ChatSlashCommand.Platform + public let conversation: ConversationModel + public let configuration: AIConfigurationModel + public let skills: SkillLibraryModel + public let archive: (() -> Void)? + public let newConversation: (() -> Void)? + public let compact: (String) -> Void + public let send: () -> Void + public let authorizeRemoteMCP: () -> Bool + public let localized: (String.LocalizationValue) -> String + + public init( + platform: ChatSlashCommand.Platform, + conversation: ConversationModel, + configuration: AIConfigurationModel, + skills: SkillLibraryModel, + archive: (() -> Void)? = nil, + newConversation: (() -> Void)? = nil, + compact: @escaping (String) -> Void, + send: @escaping () -> Void, + authorizeRemoteMCP: @escaping () -> Bool = { true }, + localized: @escaping (String.LocalizationValue) -> String = { String(localized: $0) } + ) { + self.platform = platform + self.conversation = conversation + self.configuration = configuration + self.skills = skills + self.archive = archive + self.newConversation = newConversation + self.compact = compact + self.send = send + self.authorizeRemoteMCP = authorizeRemoteMCP + self.localized = localized + } +} + +/// One independently registered slash command. +@MainActor +public struct ChatSlashCommandRegistration { + public typealias CommandBuilder = (ChatSlashCommandContext) -> ChatSlashCommand? + public typealias OptionsBuilder = (ChatSlashCommandContext) -> [ChatSlashCommand.Option] + public typealias TrailingTextBuilder = + (ChatSlashCommandCompletion.Row, ChatSlashCommandContext) -> String? + public typealias CurrentValueMatcher = + (ChatSlashCommandCompletion.Row, ChatSlashCommandContext) -> Bool + public typealias PreferredOption = (ChatSlashCommandContext) -> String? + public typealias Performer = (ChatSlashCommandInput, ChatSlashCommandContext) -> Bool + + public let id: String + let command: CommandBuilder + let options: OptionsBuilder + let trailingText: TrailingTextBuilder + let isCurrent: CurrentValueMatcher + let preferredOption: PreferredOption + let perform: Performer + + public init( + id: String, + command: @escaping CommandBuilder, + options: @escaping OptionsBuilder = { _ in [] }, + trailingText: @escaping TrailingTextBuilder = { _, _ in nil }, + isCurrent: @escaping CurrentValueMatcher = { _, _ in false }, + preferredOption: @escaping PreferredOption = { _ in nil }, + perform: @escaping Performer + ) { + self.id = id + self.command = command + self.options = options + self.trailingText = trailingText + self.isCurrent = isCurrent + self.preferredOption = preferredOption + self.perform = perform + } +} + +/// The single catalog and executor used by the macOS and mobile composers. +/// +/// Registration keeps command metadata beside its option and execution policy, +/// so adding a command cannot leave another string switch out of sync. +@MainActor +public struct ChatSlashCommandRegistry { + private var registrations: [ChatSlashCommandRegistration] = [] + + public init() {} + + public mutating func register(_ registration: ChatSlashCommandRegistration) { + precondition( + !registrations.contains { $0.id == registration.id }, + "Duplicate slash command registration: \(registration.id)" + ) + registrations.append(registration) + } + + public func commands(in context: ChatSlashCommandContext) -> [ChatSlashCommand] { + registrations.compactMap { registration in + guard let command = registration.command(context) else { return nil } + guard command.platforms.contains(context.platform) else { return nil } + return command + } + .sorted { lhs, rhs in + let order = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) + return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending + } + } + + public func options( + for command: ChatSlashCommand, + in context: ChatSlashCommandContext + ) -> [ChatSlashCommand.Option] { + registration(for: command.id)?.options(context) ?? [] + } + + public func completion( + for draft: String, + in context: ChatSlashCommandContext + ) -> ChatSlashCommandCompletion { + ChatSlashCommandCompletion.rows( + draft: draft, + commands: commands(in: context), + options: { options(for: $0, in: context) } + ) + } + + public func trailingText( + for row: ChatSlashCommandCompletion.Row, + in context: ChatSlashCommandContext + ) -> String? { + registration(for: row.commandID)?.trailingText(row, context) + } + + public func isCurrent( + _ row: ChatSlashCommandCompletion.Row, + in context: ChatSlashCommandContext + ) -> Bool { + registration(for: row.commandID)?.isCurrent(row, context) == true + } + + public func preferredSelection( + in completion: ChatSlashCommandCompletion, + context: ChatSlashCommandContext + ) -> Int { + guard case .option(let command, _) = completion.rows.first, + let option = registration(for: command.id)?.preferredOption(context) + else { return 0 } + return completion.selectionIndex(commandID: command.id, optionID: option) + } + + @discardableResult + public func perform(draft: String, in context: ChatSlashCommandContext) -> Bool { + guard let input = ChatSlashCommandInput.parse(draft), + commands(in: context).contains(where: { $0.id == input.name.lowercased() }), + let registration = registration(for: input.name.lowercased()) + else { return false } + return registration.perform(input, context) + } + + public static func standard() -> Self { + var registry = Self() + registry.register(archiveRegistration) + registry.register(newConversationRegistration) + registry.register(planRegistration) + registry.register(compactRegistration) + registry.register(reasoningRegistration) + registry.register(modelRegistration) + registry.register(mcpRegistration) + registry.register(skillRegistration) + return registry + } + + private func registration(for id: String) -> ChatSlashCommandRegistration? { + registrations.first { $0.id == id } + } +} + +extension ChatSlashCommandCompletion.Row { + fileprivate nonisolated var commandID: String { + switch self { + case .command(let command), .option(let command, _): command.id + } + } +} + +// MARK: - Standard registrations + +extension ChatSlashCommandRegistry { + private static var archiveRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "archive", + command: { context in + guard !context.conversation.isEmpty, context.archive != nil else { return nil } + return ChatSlashCommand( + id: "archive", displayName: context.localized("Archive"), + summary: context.localized("Move this conversation to Archive"), + icon: .symbol("archivebox"), platforms: [.desktop] + ) + }, + perform: { input, context in + guard !context.conversation.isStreaming, !context.conversation.isCompacting, + let archive = context.archive + else { return false } + context.conversation.composer.draft = input.leadingText + archive() + return true + } + ) + } + + private static var newConversationRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "new", + command: { context in + guard !context.conversation.isEmpty, context.newConversation != nil else { return nil } + return ChatSlashCommand( + id: "new", displayName: context.localized("New chat"), + summary: context.localized("Start a new conversation"), + icon: .symbol("plus.bubble"), platforms: [.desktop] + ) + }, + perform: { input, context in + guard let newConversation = context.newConversation else { return false } + context.conversation.composer.draft = input.leadingText + newConversation() + return true + } + ) + } + + private static var planRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "plan", + command: { context in + ChatSlashCommand( + id: "plan", displayName: context.localized("Plan mode"), + summary: context.conversation.isPlanning + ? context.localized("Stop planning and act") + : context.localized("Research and propose before acting"), + icon: .symbol("lightbulb.max"), argumentHint: context.localized("what to plan") + ) + }, + perform: { input, context in + let question = input.argumentText(trimmed: context.platform == .desktop) + if question.isEmpty { + context.conversation.isPlanning.toggle() + context.conversation.composer.draft = input.leadingText + } else { + context.conversation.isPlanning = true + context.conversation.composer.draft = input.leadingText + question + if input.leadingText.isEmpty { context.send() } + } + return true + } + ) + } + + private static var compactRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "compact", + command: { context in + guard !context.conversation.isEmpty else { return nil } + return ChatSlashCommand( + id: "compact", displayName: context.localized("Compact"), + summary: context.localized("Summarize older turns to free context"), + icon: .contextRing, argumentHint: context.localized("what to emphasize") + ) + }, + perform: { input, context in + if context.platform == .mobile, + context.conversation.isStreaming || context.conversation.isCompacting + { + return true + } + let instructions = input.argumentText(trimmed: context.platform == .desktop) + context.conversation.composer.draft = input.leadingText + context.compact(instructions) + return true + } + ) + } + + private static var reasoningRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "reasoning", + command: { context in + guard context.reasoning?.supported.contains(where: { $0 != .off }) == true else { return nil } + return ChatSlashCommand( + id: "reasoning", displayName: context.localized("Reasoning"), + summary: context.localized("How hard the model thinks"), icon: .symbol("brain"), + argumentHint: context.localized("effort"), platforms: [.desktop] + ) + }, + options: { context in + context.reasoning?.supported.map { + ChatSlashCommand.Option(id: $0.rawValue, label: $0.displayName) + } ?? [] + }, + trailingText: { row, context in + switch row { + case .command: + return context.effectiveReasoningEffort.displayName + case .option(_, let option): + return context.platform == .desktop && option.id == context.effectiveReasoningEffort.rawValue + ? context.localized("Current") : nil + } + }, + isCurrent: { row, context in + guard case .option(_, let option) = row else { return false } + return option.id == context.effectiveReasoningEffort.rawValue + }, + preferredOption: { $0.effectiveReasoningEffort.rawValue }, + perform: { input, context in + guard case .argument(_, _, let argument) = input, + let effort = ReasoningEffort(rawValue: argument.lowercased()), + context.reasoning?.supported.contains(effort) == true + else { return false } + context.conversation.reasoningEffort = effort + context.conversation.composer.draft = input.leadingText + return true + } + ) + } + + private static var modelRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "model", + command: { context in + guard !context.modelChoices.isEmpty else { return nil } + return ChatSlashCommand( + id: "model", displayName: context.localized("Model"), + summary: context.localized("Switch the model for this conversation"), icon: .symbol("sparkles"), + argumentHint: context.localized("model"), platforms: [.desktop] + ) + }, + options: { context in + context.modelChoices.map { + ChatSlashCommand.Option( + id: $0.argument, label: $0.model.label, + detail: context.platform == .mobile ? $0.provider.name : "" + ) + } + }, + trailingText: { row, context in + switch row { + case .command: + return context.resolved?.model.label ?? context.localized("No model") + case .option(_, let option): + guard context.platform == .desktop else { return nil } + return context.modelChoices.first { $0.argument == option.id }?.provider.name + } + }, + isCurrent: { row, context in + guard case .option(_, let option) = row else { return false } + return option.id == context.resolvedModelArgument + }, + preferredOption: { $0.resolvedModelArgument }, + perform: { input, context in + guard case .argument(_, _, let argument) = input, + let choice = context.modelChoices.first(where: { $0.argument == argument }) + else { return false } + let reference = AIModelRef(modelProviderID: choice.provider.id, modelID: choice.model.id) + context.conversation.model = reference + context.configuration.select(reference) + context.conversation.reasoningEffort = ModelCapabilityResolver.reasoning( + model: choice.model, provider: choice.provider + ).clamp(context.conversation.reasoningEffort) + context.conversation.composer.draft = input.leadingText + return true + } + ) + } + + private static var mcpRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "mcp", + command: { context in + guard !context.configuration.mcpServers.isEmpty else { return nil } + return ChatSlashCommand( + id: "mcp", displayName: context.localized("MCP"), + summary: context.localized("Enable or disable MCP servers"), icon: .symbol("server.rack"), + argumentHint: context.localized("server"), platforms: [.desktop] + ) + }, + options: { context in + context.configuration.mcpServers.map { + ChatSlashCommand.Option(id: $0.id.uuidString.lowercased(), label: $0.name) + } + }, + trailingText: { row, context in + guard case .option(_, let option) = row, + let id = UUID(uuidString: option.id), + let server = context.configuration.mcpServer(id) + else { return nil } + return server.isEnabled ? context.localized("Enabled") : context.localized("Disabled") + }, + perform: { input, context in + guard case .argument(_, _, let argument) = input, + let id = UUID(uuidString: argument), + let server = context.configuration.mcpServer(id), + context.authorizeRemoteMCP() + else { return false } + context.configuration.setServerEnabled(!server.isEnabled, for: id) + context.conversation.composer.draft = input.leadingText + return true + } + ) + } + + private static var skillRegistration: ChatSlashCommandRegistration { + ChatSlashCommandRegistration( + id: "skill", + command: { context in + guard !context.skills.enabledSkills.isEmpty else { return nil } + return ChatSlashCommand( + id: "skill", displayName: context.localized("Skill"), + summary: context.localized("Use an installed skill"), icon: .symbol("book.closed"), + argumentHint: context.localized("skill") + ) + }, + options: { context in + context.skills.enabledSkills.map { + ChatSlashCommand.Option( + id: $0.effectiveName, label: $0.displayTitle, + detail: context.platform == .mobile ? $0.summary : "" + ) + } + }, + trailingText: { row, _ in + guard case .option(_, let option) = row else { return nil } + return option.id + }, + isCurrent: { row, context in + guard case .option(_, let option) = row else { return false } + return option.id == context.conversation.composer.armedSkillName + }, + perform: { input, context in + guard let selection = input.selection, + context.skills.enabledSkills.contains(where: { $0.effectiveName == selection.name }) + else { return false } + context.conversation.composer.armedSkillName = selection.name + context.conversation.composer.draft = input.leadingText + selection.remainder + if input.leadingText.isEmpty, !selection.remainder.isEmpty { context.send() } + return true + } + ) + } +} + +extension ChatSlashCommandContext { + fileprivate typealias ModelChoice = (argument: String, provider: ModelProvider, model: AIModel) + + fileprivate var modelChoices: [ModelChoice] { + configuration.enabledModelProviders.flatMap { provider in + provider.assistantModels.map { model in + ( + ChatSlashCommand.argumentID( + scope: provider.id.uuidString.lowercased(), value: model.id + ), + provider, + model + ) + } + } + } + + fileprivate var resolved: (provider: ModelProvider, model: AIModel)? { + configuration.resolve(conversation: conversation.model) + } + + fileprivate var reasoning: ModelCapabilityResolver.ReasoningResolution? { + guard let resolved else { return nil } + return ModelCapabilityResolver.reasoning(model: resolved.model, provider: resolved.provider) + } + + fileprivate var effectiveReasoningEffort: ReasoningEffort { + reasoning?.clamp(conversation.reasoningEffort) ?? .off + } + + fileprivate var resolvedModelArgument: String? { + guard let resolved else { return nil } + return ChatSlashCommand.argumentID( + scope: resolved.provider.id.uuidString.lowercased(), value: resolved.model.id + ) + } +} + +extension ChatSlashCommandInput { + fileprivate nonisolated func argumentText(trimmed: Bool) -> String { + guard case .argument(_, _, let text) = self else { return "" } + return trimmed ? text.trimmingCharacters(in: .whitespacesAndNewlines) : text + } +} diff --git a/Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift b/Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift new file mode 100644 index 00000000..8d1675e5 --- /dev/null +++ b/Frameworks/TermindApplication/Connections/VaultRemoteChangePlan.swift @@ -0,0 +1,49 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// Platform-neutral effects required after a remote Vault snapshot changes +/// connection-defining Host fields. +public nonisolated struct VaultRemoteChangePlan: Sendable { + public nonisolated struct HostChange: Sendable { + public let id: Host.ID + public let isRemoved: Bool + public let routeChanged: Bool + } + + public let hosts: [HostChange] + public let forwardingRuleIDs: [PortForwardingRule.ID] + + public static func make( + currentHosts: [Host], + snapshot: VaultSnapshot, + changedKeys: Set + ) -> VaultRemoteChangePlan { + let current = Dictionary(uniqueKeysWithValues: currentHosts.map { ($0.id, $0) }) + let next = Dictionary(uniqueKeysWithValues: snapshot.hosts.map { ($0.id, $0) }) + let hosts = changedKeys.compactMap { key -> HostChange? in + guard key.kind == .host, let old = current[key.id] else { return nil } + let updated = next[key.id] + return HostChange( + id: key.id, + isRemoved: updated == nil, + routeChanged: updated.map { !old.hasSameConnectionRoute(as: $0) } ?? true + ) + } + return VaultRemoteChangePlan( + hosts: hosts, + forwardingRuleIDs: changedKeys.filter { $0.kind == .portForwarding }.map(\.id) + ) + } +} + +extension Host { + fileprivate nonisolated func hasSameConnectionRoute(as other: Host) -> Bool { + host == other.host + && port == other.port + && identityID == other.identityID + && jumpHostID == other.jumpHostID + && proxy == other.proxy + } +} diff --git a/Frameworks/TermindApplication/Events/HostDeletionHandlers.swift b/Frameworks/TermindApplication/Events/HostDeletionHandlers.swift index 87b84e73..8b5b6378 100644 --- a/Frameworks/TermindApplication/Events/HostDeletionHandlers.swift +++ b/Frameworks/TermindApplication/Events/HostDeletionHandlers.swift @@ -40,3 +40,40 @@ public final class RuntimeHostDeletionHandler: OutboxEventHandler { action(try JSONDecoder().decode(HostDeletionPayload.self, from: payload)) } } + +extension OutboxRunner { + /// Registers the ordered host-deletion saga shared by both app shells. + /// Runtime cleanup stays injected; repository cleanup keeps its durable IDs. + public func registerHostDeletionHandlers( + repository: any HostRepository, + closeWorkspaces: @escaping (HostDeletionPayload) -> Void, + cancelTransfers: @escaping (HostDeletionPayload) -> Void, + stopForwardings: @escaping (HostDeletionPayload) -> Void + ) { + register( + RuntimeHostDeletionHandler( + handlerID: HostDeletionOutbox.closeWorkspaces, + action: closeWorkspaces + )) + register( + RuntimeHostDeletionHandler( + handlerID: HostDeletionOutbox.cancelTransfers, + action: cancelTransfers + )) + register( + RuntimeHostDeletionHandler( + handlerID: HostDeletionOutbox.stopForwardings, + action: stopForwardings + )) + register( + RepositoryHostDeletionHandler( + handlerID: HostDeletionOutbox.clearReferences, + repository: repository + )) + register( + RepositoryHostDeletionHandler( + handlerID: HostDeletionOutbox.finalize, + repository: repository + )) + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/LibrarySectionLayout.swift b/Frameworks/TermindApplication/FeatureModels/LibrarySectionLayout.swift new file mode 100644 index 00000000..5729d462 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/LibrarySectionLayout.swift @@ -0,0 +1,136 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated struct HostLibrarySection: Identifiable, Sendable { + public nonisolated enum ID: Hashable, Sendable { + case results + case group(HostGroup.ID) + case ungrouped + + public var groupID: HostGroup.ID? { + guard case .group(let id) = self else { return nil } + return id + } + } + + public let id: ID + public let title: String + public let hosts: [Host] + public let isExpanded: Bool? + + public init(id: ID, title: String, hosts: [Host], isExpanded: Bool? = nil) { + self.id = id + self.title = title + self.hosts = hosts + self.isExpanded = isExpanded + } +} + +@MainActor +public enum HostLibrarySectionLayout { + public static func sections( + library: HostLibraryModel, + query: String = "", + selectedTags: Set = [] + ) -> [HostLibrarySection] { + let isFiltering = !query.isEmpty || !selectedTags.isEmpty + if isFiltering { + let matches = library.searchHosts(query).filter { $0.hasTags(selectedTags) } + return matches.isEmpty + ? [] + : [HostLibrarySection(id: .results, title: String(localized: "Results"), hosts: matches)] + } + + var result: [HostLibrarySection] = [] + func append(_ group: HostGroup, prefix: String) { + let title = prefix.isEmpty ? group.name : "\(prefix) / \(group.name)" + result.append( + HostLibrarySection( + id: .group(group.id), title: title, + hosts: library.hosts(in: group.id), isExpanded: group.isExpanded + )) + guard group.isExpanded else { return } + for child in library.childGroups(of: group.id) { + append(child, prefix: title) + } + } + for group in library.rootGroups { append(group, prefix: "") } + + let ungrouped = library.hosts(in: nil) + if !ungrouped.isEmpty || !library.groups.isEmpty { + result.append( + HostLibrarySection( + id: .ungrouped, title: String(localized: "Ungrouped"), + hosts: ungrouped, isExpanded: library.isUngroupedExpanded + )) + } + return result + } +} + +public nonisolated struct SnippetLibrarySection: Identifiable, Sendable { + public nonisolated enum ID: Hashable, Sendable { + case results + case package(SnippetPackage.ID) + case ungrouped + + public var packageID: SnippetPackage.ID? { + guard case .package(let id) = self else { return nil } + return id + } + } + + public let id: ID + public let title: String + public let snippets: [Snippet] + public let isExpanded: Bool? + + public init(id: ID, title: String, snippets: [Snippet], isExpanded: Bool? = nil) { + self.id = id + self.title = title + self.snippets = snippets + self.isExpanded = isExpanded + } +} + +@MainActor +public enum SnippetLibrarySectionLayout { + public static func sections( + library: SnippetLibraryModel, + query: String = "" + ) -> [SnippetLibrarySection] { + if !query.isEmpty { + let matches = library.searchSnippets(query) + return matches.isEmpty + ? [] + : [SnippetLibrarySection(id: .results, title: String(localized: "Results"), snippets: matches)] + } + + var result: [SnippetLibrarySection] = [] + func append(_ package: SnippetPackage, prefix: String) { + let title = prefix.isEmpty ? package.name : "\(prefix) / \(package.name)" + result.append( + SnippetLibrarySection( + id: .package(package.id), title: title, + snippets: library.snippets(in: package.id), isExpanded: package.isExpanded + )) + guard package.isExpanded else { return } + for child in library.childSnippetPackages(of: package.id) { + append(child, prefix: title) + } + } + for package in library.rootSnippetPackages { append(package, prefix: "") } + + let ungrouped = library.snippets(in: nil) + if !ungrouped.isEmpty || !library.snippetPackages.isEmpty { + result.append( + SnippetLibrarySection( + id: .ungrouped, title: String(localized: "Ungrouped"), + snippets: ungrouped, isExpanded: library.isSnippetsUngroupedExpanded + )) + } + return result + } +} diff --git a/Frameworks/TermindDomain/AI/AIModelEditing.swift b/Frameworks/TermindDomain/AI/AIModelEditing.swift new file mode 100644 index 00000000..67801584 --- /dev/null +++ b/Frameworks/TermindDomain/AI/AIModelEditing.swift @@ -0,0 +1,37 @@ +import AgentKit + +nonisolated + + extension AIModel +{ + public var withoutReasoningOverride: AIModel { + var copy = self + copy.reasoningProfile = nil + return copy + } + + /// Updates one reasoning level while preserving at least one supported + /// effort. Returns false when disabling the final supported effort is + /// rejected. + public mutating func setReasoningEffort(_ effort: ReasoningEffort, isSupported: Bool) -> Bool { + let supportedCount = reasoningProfile?.levels.values.filter(\.isSupported).count ?? 0 + guard isSupported || supportedCount > 1 else { return false } + updateReasoningMapping(effort) { $0.isSupported = isSupported } + return true + } + + public mutating func setReasoningWireValue(_ value: String?, for effort: ReasoningEffort) { + updateReasoningMapping(effort) { $0.wireValue = value } + } + + private mutating func updateReasoningMapping( + _ effort: ReasoningEffort, + update: (inout ReasoningLevelMapping) -> Void + ) { + var profile = reasoningProfile ?? ReasoningProfile() + var mapping = profile.mapping(for: effort) + update(&mapping) + profile.levels[effort] = mapping + reasoningProfile = profile + } +} diff --git a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift index 8b3b72f2..4f4e7a89 100644 --- a/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift +++ b/Frameworks/TermindDomain/Connections/ConnectionHistoryEntry.swift @@ -5,7 +5,7 @@ import Foundation /// Every attempt is retained as its own event so the History sidebar can show /// repeated visits to the same destination. public nonisolated struct ConnectionHistoryEntry: Identifiable, Hashable, Sendable { - public nonisolated enum Kind: String, Hashable, Sendable { + public nonisolated enum Kind: String, CaseIterable, Hashable, Sendable { case ssh case sftp diff --git a/Frameworks/TermindDomain/Snippets/SnippetEditing.swift b/Frameworks/TermindDomain/Snippets/SnippetEditing.swift new file mode 100644 index 00000000..6dc512e2 --- /dev/null +++ b/Frameworks/TermindDomain/Snippets/SnippetEditing.swift @@ -0,0 +1,14 @@ +import Foundation + +nonisolated + + extension Snippet +{ + public func duplicated() -> Snippet { + var copy = self + copy.id = UUID() + copy.title = String(localized: "\(title) Copy") + copy.sortIndex = 0 + return copy + } +} diff --git a/Frameworks/TermindPresentation/CommandHistoryPresentation.swift b/Frameworks/TermindPresentation/CommandHistoryPresentation.swift new file mode 100644 index 00000000..ac0001f7 --- /dev/null +++ b/Frameworks/TermindPresentation/CommandHistoryPresentation.swift @@ -0,0 +1,24 @@ +import Foundation +import TermindDomain + +public nonisolated enum CommandHistoryPresentation { + public static func subtitle(for entry: CommandHistoryEntry, locale: Locale) -> String { + var parts = [ + entry.executedAt.formatted( + .relative(presentation: .numeric).locale(locale) + ) + ] + if let duration = entry.duration, let formatted = formattedDuration(duration) { + parts.append(formatted) + } + return parts.joined(separator: " · ") + } + + private static func formattedDuration(_ duration: TimeInterval) -> String? { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 2 + formatter.allowedUnits = duration < 60 ? [.second] : [.hour, .minute, .second] + return formatter.string(from: max(0, duration)) + } +} diff --git a/Frameworks/TermindPresentation/SFTP/SFTPItemType.swift b/Frameworks/TermindPresentation/SFTP/SFTPItemType.swift new file mode 100644 index 00000000..6d987985 --- /dev/null +++ b/Frameworks/TermindPresentation/SFTP/SFTPItemType.swift @@ -0,0 +1,5 @@ +import UniformTypeIdentifiers + +nonisolated extension UTType { + public static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items") +} diff --git a/Frameworks/TermindPresentation/VaultSyncStatusLabel.swift b/Frameworks/TermindPresentation/VaultSyncStatusLabel.swift new file mode 100644 index 00000000..59129db0 --- /dev/null +++ b/Frameworks/TermindPresentation/VaultSyncStatusLabel.swift @@ -0,0 +1,40 @@ +import SwiftUI +import TermindDomain + +public struct VaultSyncStatusLabel: View { + public let status: VaultSyncStatus + @Environment(\.locale) private var locale + + public init(status: VaultSyncStatus) { self.status = status } + + @ViewBuilder + public var body: some View { + switch status { + case .checkingAccount: + Label("Checking iCloud…", systemImage: "icloud") + case .paused: + Label("Paused", systemImage: "pause.circle") + case .unavailable(let message): + Label(message, systemImage: "icloud.slash").foregroundStyle(.secondary) + case .accountChangeConfirmationRequired: + Label("Waiting for confirmation", systemImage: "person.crop.circle.badge.exclamationmark") + .foregroundStyle(.orange) + case .syncing(let pendingCount): + Label( + pendingCount == 1 ? "Syncing 1 change…" : "Syncing \(pendingCount) changes…", + systemImage: "arrow.triangle.2.circlepath.icloud" + ) + case .upToDate(let lastSync): + if let lastSync { + let formatted = lastSync.formatted( + Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale) + ) + Label("Up to date · \(formatted)", systemImage: "checkmark.icloud") + } else { + Label("Up to date", systemImage: "checkmark.icloud") + } + case .failed(let message): + Label(message, systemImage: "exclamationmark.icloud").foregroundStyle(.red) + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift new file mode 100644 index 00000000..1bbcd97e --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentHostServices.swift @@ -0,0 +1,42 @@ +import TermindApplication + +/// The shared live host-facing services used by Assistant and the MCP bridge. +/// App shells register these services with their execution surfaces instead of +/// rebuilding the SSH/SFTP connection stack. +@MainActor +public struct AgentHostServices { + public let resolver: AgentHostResolver + public let connections: AgentConnectionPool + public let authentication: AgentSSHAuthenticationRegistry + public let background: AgentBackgroundCommands + + public init( + resolver: AgentHostResolver, + connections: AgentConnectionPool, + authentication: AgentSSHAuthenticationRegistry, + background: AgentBackgroundCommands + ) { + self.resolver = resolver + self.connections = connections + self.authentication = authentication + self.background = background + } + + public static func live( + store: HostLibraryModel, + coordinator: RemoteConnectionCoordinator = RemoteConnectionCoordinator() + ) -> AgentHostServices { + let resolver = AgentHostResolver(store: store) + return AgentHostServices( + resolver: resolver, + connections: AgentConnectionPool( + configuration: { try await resolver.configuration(for: $0) }, + routeFingerprint: { try await resolver.routeFingerprint(for: $0) }, + openExec: { try await coordinator.connectExec(using: $0) }, + openSFTP: { try await coordinator.connectSFTP(using: $0) } + ), + authentication: AgentSSHAuthenticationRegistry(resolver: resolver), + background: AgentBackgroundCommands() + ) + } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 124b5cc8..80e82b44 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -140,6 +140,9 @@ The additive seams are: - `AgentToolCatalog.registry(builtIn:additional:)`: AgentKit groups first, then `TermindAgentTools`; Termind and remote MCP tools cannot shadow built-ins. +- `ChatSlashCommandRegistry`: one registration owns a command's platforms, + availability, options, current-value presentation, and execution. Desktop and + mobile composers inject only host navigation, compaction, send, and access actions. - `AgentToolServices`: typed host services, including `AgentSSHInteraction`, keep credentials and host-key prompts outside AgentKit events and durable history. - `TermindAgentPolicy`: Termind's prompt, plan contract, and `ssh_exec` decision; @@ -358,6 +361,24 @@ destination restores the original interaction and clears targeting. File-provide representations are copied within their callbacks into app-owned staging before upload preparation; queued transfers retain those files after navigation away. +Mobile top-level destinations are registered in `MobileFeatureRegistry`. A +registration owns stable identity, label, symbol, phone/iPad placement, ordering, +and its destination factory. The root shell renders that catalog and does not +maintain separate tab, sidebar, and destination switches. Registries are injected +values rather than global singletons, and duplicate feature IDs fail immediately. + +Mobile Workspace descriptors use Domain's `ConnectionHistoryEntry.Kind` as the +single SSH/SFTP kind vocabulary. Host Picker and Snippet operation state live in +the mobile Application layer rather than beside their views. Live Metrics samplers +enter through `MobileMetricsServices`, while both app shells obtain Agent host +resolution, authentication, connections, and background commands from the shared +Remote Infrastructure `AgentHostServices` composition. + +Application derives flattened Host and Snippet sections once for both platforms. +Presentation owns the shared command-history subtitle, Vault sync status label, +and the exported SFTP drag type. App targets retain native card/list composition, +selection, menus, and clipboard behavior. + ### Assistant transcript presentation parity (2026-09-12) diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index c553da00..afd6a845 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -1318,13 +1318,14 @@ remains behind the context menu and confirmation dialog. ## 2026-09-12 — Share Assistant transcript presentation -**Status:** In progress. +**Status:** Completed before the 2026-09-13 registration pass. **Evidence:** `rg` confirms mobile renders literal Worked/tool names and duplicates result projection, while desktop owns AgentToolPresentation (1,087 lines), disclosure/deadline state, timestamps and Markdown policy. **Action:** Move platform-neutral presentation into TermindPresentation, inject tool presenters from the existing registry, and retain native platform rendering and viewport ownership. Replace mobile disclosure and detail branches with shared policy consumers. Estimated duplicate-policy reduction: 150–250 lines, with desktop presentation code moved rather than copied. -**Verification:** Pending shared, desktop, mobile and bilingual rendering checks. +**Verification:** Shared policy and both native renderers are recorded in `ARCHITECTURE.md`; the subsequent +cross-platform registration pass keeps those boundaries intact and both app builds pass. ## 2026-09-12 — Remove Periphery-confirmed dead code @@ -1383,3 +1384,56 @@ seven entries. **Result:** The app catalog fell from 1,637 to 1,556 entries and now contains no stale entries. Both app builds pass with pinned dependencies; `AppLanguageTests`, both Simplified Chinese catalog checks, direct string-catalog compilation, strict formatting for the touched test, and `git diff --check` pass. + +## 2026-09-13 — Register cross-platform Assistant slash commands + +**Status:** Completed; both app builds, direct registry tests, desktop Slash Command tests, and the focused mobile +Assistant tests pass. + +**Evidence:** `ComposerView` and `MobileAssistantComposer` separately built the same Plan, Compact, Reasoning, +Model, MCP, and Skill descriptors, then repeated option lookup, current-value presentation, completion acceptance, +and execution switches. Mobile filtered commands through `isShownOnMobile` but retained unreachable handlers for +the commands it had already removed. + +**Action:** Add a value-based `ChatSlashCommandRegistry`. Each registration owns its descriptor, explicit +`platforms` array, availability, options, trailing/current presentation, preferred option, and execution. Desktop +and mobile inject only host-owned navigation, compaction, send, access, and localization actions. Replace the +mobile-only Boolean with the extensible `desktop` / `mobile` platform vocabulary. + +**Result:** Both composers consume one catalog and executor, mobile no longer contains unreachable command branches, +and duplicate registration IDs fail immediately. The parser and stored conversation formats are unchanged. + +## 2026-09-13 — Register mobile feature destinations + +**Status:** Completed; the iOS build and all 28 focused mobile navigation parity tests pass. + +**Evidence:** `MobileRootView`, `MobileNavigation`, and `MobileSceneModel` maintained separate phone tabs, iPad +sidebar rows, localized metadata, selection conversion, and a destination switch. Workspace list content also made +the root shell responsible for feature-specific navigation and sheets. + +**Action:** Add an injected `MobileFeatureRegistry`. A registration owns destination identity, localized label, +symbol, ordered phone/iPad placements, and a destination factory. Move the Workspace list into its feature folder +and use `MobileDestination` directly as the tab selection. + +**Result:** The root shell renders one catalog for both device families and no longer knows how individual features +construct their views. The registry remains app-owned and uses stable IDs; it does not add a global service locator. + +## 2026-09-13 — Share remaining mobile and desktop policies + +**Status:** Completed; both app builds, Architecture Boundary tests, four direct shared-policy tests, and focused +mobile App, Assistant, Card, Picker, Drop, Toolbar, and Navigation tests pass. + +**Evidence:** The app targets duplicated Host/Snippet tree flattening, Host route-change detection, five Host deletion +outbox registrations, Command History duration formatting, Vault sync status mapping, Snippet duplication, and the +exported SFTP drag type. TFI also declared a second SSH/SFTP Workspace kind and constructed live Agent/Metrics +services inside Application models. + +**Action:** Move tree layouts and remote-change planning to Application; register the Host deletion saga once; +move shared display values and the SFTP drag type to Presentation; move Snippet and AI-model edit rules to Domain; +reuse `ConnectionHistoryEntry.Kind`; compose Agent host services in Remote Infrastructure; and inject the mobile +Metrics sampler through `MobileMetricsServices`. Move Host Picker, Snippet operation, and Workspace descriptor state +out of view files. + +**Result:** Cross-platform policy now has one owner while native lists, cards, menus, selection, and transport +lifecycle remain platform-specific. No persistence schema, dependency version, remote protocol, or localization key +changed. The deliberate AppKit/UIKit and complete SSH/SFTP session boundaries remain intact. diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index 01d3f478..78c6f94c 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -9,6 +9,7 @@ struct MobileAppDependencies { var forwardingDefaults: UserDefaults? var background: any MobileBackgroundTaskManaging var workspaceConnections: MobileWorkspaceConnectionServices + var metrics: MobileMetricsServices static func live() -> MobileAppDependencies { MobileAppDependencies( @@ -17,7 +18,8 @@ struct MobileAppDependencies { defaults: .standard, forwardingDefaults: .standard, background: MobileBackgroundController(), - workspaceConnections: .live + workspaceConnections: .live, + metrics: .live ) } @@ -26,7 +28,8 @@ struct MobileAppDependencies { defaults: UserDefaults? = nil, forwardingDefaults: UserDefaults? = nil, background: any MobileBackgroundTaskManaging = MobileBackgroundController(), - workspaceConnections: MobileWorkspaceConnectionServices = .live + workspaceConnections: MobileWorkspaceConnectionServices = .live, + metrics: MobileMetricsServices = .testing ) -> MobileAppDependencies { MobileAppDependencies( hosts: hosts, @@ -34,7 +37,8 @@ struct MobileAppDependencies { defaults: defaults, forwardingDefaults: forwardingDefaults, background: background, - workspaceConnections: workspaceConnections + workspaceConnections: workspaceConnections, + metrics: metrics ) } } diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index d3e1e0dc..ec38d0b8 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -82,7 +82,8 @@ final class MobileAppModel { defaults: defaults, forwardingDefaults: forwardingDefaults ?? (inMemory || hosts != nil ? nil : defaults), background: background, - workspaceConnections: workspaceConnectionServices + workspaceConnections: workspaceConnectionServices, + metrics: inMemory || hosts != nil ? .testing : .live )) } @@ -146,12 +147,20 @@ final class MobileAppModel { commandGenerator = CommandGeneratorController(ai: ai) skills = SkillLibraryModel(persistence: SyncedSkillLibraryPersistence(settingsPersistence)) library = HostLibraryModel(persistence: persistence, secrets: secrets) - metrics = MobileMetricsModel(preferences: metricsPersistence, hosts: library) + metrics = MobileMetricsModel(preferences: metricsPersistence, hosts: library, services: dependencies.metrics) + let speechAudio = IOSSpeechAudioInput() + let speech = SpeechTranscriptionCoordinator( + service: AppleSpeechTranscriptionService(audioInput: speechAudio) + ) + speechAudio.onInterruption = { [weak speech] in speech?.cancelAll() } assistant = MobileAssistantModel( configuration: ai, repository: SwiftDataAgentRunRepository(context: ModelContext(container)), - hosts: library, metrics: metrics.core, skills: skills, - authorize: { [pro] in pro.require(.assistant) }) + hostServices: AgentHostServices.live(store: library), metrics: metrics.core, skills: skills, + authorize: { [pro] in pro.require(.assistant) }, + speech: speech, + attachmentLoader: .live + ) credentials = CredentialLibraryModel(library: library) snippets = SnippetLibraryModel(library: library) knownHosts = KnownHostsLibraryModel(library: library) @@ -162,26 +171,23 @@ final class MobileAppModel { library.didReplacePortForwardings = { [weak forwardings] ids in forwardings?.resumeEnabled(ids) } let visibleHostIDs = Set(try self.hosts.snapshot().value.hosts.map(\.id)) for host in library.hosts where !visibleHostIDs.contains(host.id) { library.projectHostDeletion(host.id) } - outbox.register( - RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.closeWorkspaces) { [weak self] payload in + outbox.registerHostDeletionHandlers( + repository: self.hosts, + closeWorkspaces: { [weak self] payload in guard let self else { return } for workspace in workspaces.filter({ $0.descriptor.hostID == payload.hostID }) { close(workspace.id) } commandHistory.clear(for: payload.hostID) sftpHistory.clear(for: payload.hostID) library.projectHostDeletion(payload.hostID) - }) - outbox.register( - RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.cancelTransfers) { [weak self] payload in + }, + cancelTransfers: { [weak self] payload in guard let self else { return } transfers.cancelAll(for: payload.hostID) - }) - outbox.register( - RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.stopForwardings) { [weak forwardings] payload in + }, + stopForwardings: { [weak forwardings] payload in forwardings?.remove(payload.forwardingRuleIDs) - }) - outbox.register( - RepositoryHostDeletionHandler(handlerID: HostDeletionOutbox.clearReferences, repository: self.hosts)) - outbox.register(RepositoryHostDeletionHandler(handlerID: HostDeletionOutbox.finalize, repository: self.hosts)) + } + ) library.didRecordVaultChanges = { [weak vaultSync] in vaultSync?.noteLocalChanges($0) } if let repository = self.hosts as? SwiftDataHostRepository { repository.didRecordVaultChanges = { [weak vaultSync] in vaultSync?.noteLocalChanges($0) } @@ -198,7 +204,7 @@ final class MobileAppModel { } func open( - hostID: UUID, kind: MobileWorkspaceDescriptor.Kind, + hostID: UUID, kind: ConnectionHistoryEntry.Kind, initialPath: String? = nil ) -> UUID? { guard let host = library.host(hostID) else { return nil } @@ -332,30 +338,26 @@ final class MobileAppModel { } func applyRemoteVault(_ snapshot: VaultSnapshot, keys: Set) { - let changed = Set(keys.filter { $0.kind == .host }.map(\.id)) - for id in changed { - guard let old = library.host(id) else { continue } - let next = snapshot.hosts.first { $0.id == id } - let removed = next == nil - let routeChanged = - next.map { - old.host != $0.host || old.port != $0.port || old.identityID != $0.identityID - || old.jumpHostID != $0.jumpHostID || old.proxy != $0.proxy - } ?? true - if routeChanged { - for workspace in workspaces.filter({ $0.descriptor.hostID == id }) { - if removed { close(workspace.id) } else { workspace.disconnect() } + let plan = VaultRemoteChangePlan.make( + currentHosts: library.hosts, + snapshot: snapshot, + changedKeys: keys + ) + for change in plan.hosts { + if change.routeChanged { + for workspace in workspaces.filter({ $0.descriptor.hostID == change.id }) { + if change.isRemoved { close(workspace.id) } else { workspace.disconnect() } } - transfers.cancelAll(for: id) - forwardings.suspend(library.portForwardings(for: id).map(\.id)) + transfers.cancelAll(for: change.id) + forwardings.suspend(library.portForwardings(for: change.id).map(\.id)) } - if removed { - commandHistory.clear(for: id) - sftpHistory.clear(for: id) - metrics.remove(id) + if change.isRemoved { + commandHistory.clear(for: change.id) + sftpHistory.clear(for: change.id) + metrics.remove(change.id) } } - forwardings.suspend(keys.filter { $0.kind == .portForwarding }.map(\.id)) + forwardings.suspend(plan.forwardingRuleIDs) library.replacePersistedSnapshot(snapshot) forwardings.resumeEnabled() liveActivities.update(workspaceActivitySessions) @@ -371,9 +373,9 @@ final class MobileAppModel { } private func makeWorkspace( - host: Host, kind: MobileWorkspaceDescriptor.Kind, startup: MobileWorkspaceStartup = .none + host: Host, kind: ConnectionHistoryEntry.Kind, startup: MobileWorkspaceStartup = .none ) -> MobileWorkspace { - connectionHistory.record(kind: kind == .ssh ? .ssh : .sftp, for: host.id) + connectionHistory.record(kind: kind, for: host.id) let initialCommand: String? let recordsInitialCommandInHistory: Bool let initialSFTPPath: String? diff --git a/TFI/App/MobileFeatureRegistry.swift b/TFI/App/MobileFeatureRegistry.swift new file mode 100644 index 00000000..2b7325a3 --- /dev/null +++ b/TFI/App/MobileFeatureRegistry.swift @@ -0,0 +1,166 @@ +import SwiftUI + +enum MobileFeatureSurface: Hashable, Sendable { + case phoneTab + case padSidebar +} + +struct MobileFeaturePlacement: Hashable, Sendable { + let surface: MobileFeatureSurface + let order: Int +} + +/// One top-level mobile destination and the surfaces that host it. +@MainActor +struct MobileFeatureRegistration: Identifiable { + typealias Builder = (MobileAppModel, MobileSceneModel, MobileFeatureSurface) -> AnyView + + let id: MobileDestination + let title: LocalizedStringKey + let symbol: String + let placements: [MobileFeaturePlacement] + private let builder: Builder + + init( + id: MobileDestination, + title: LocalizedStringKey, + symbol: String, + placements: [MobileFeaturePlacement], + builder: @escaping Builder + ) { + self.id = id + self.title = title + self.symbol = symbol + self.placements = placements + self.builder = builder + } + + func makeView( + app: MobileAppModel, + scene: MobileSceneModel, + surface: MobileFeatureSurface + ) -> AnyView { + builder(app, scene, surface) + } +} + +/// The composition root for mobile navigation. +/// +/// Features register their identity, placement and destination together. The +/// shell renders registrations and no longer maintains parallel tab, sidebar, +/// metadata and destination switches. +@MainActor +struct MobileFeatureRegistry { + private(set) var registrations: [MobileFeatureRegistration] = [] + + mutating func register(_ registration: MobileFeatureRegistration) { + precondition( + !registrations.contains { $0.id == registration.id }, + "Duplicate mobile feature registration: \(registration.id.rawValue)" + ) + precondition( + !registration.placements.isEmpty, + "Mobile feature has no placement: \(registration.id.rawValue)" + ) + registrations.append(registration) + } + + func registrations(on surface: MobileFeatureSurface) -> [MobileFeatureRegistration] { + var placed: [(registration: MobileFeatureRegistration, order: Int)] = [] + for registration in registrations { + guard let placement = registration.placements.first(where: { $0.surface == surface }) else { continue } + placed.append((registration, placement.order)) + } + placed.sort { $0.order < $1.order } + return placed.map(\.registration) + } + + func registration(for destination: MobileDestination) -> MobileFeatureRegistration? { + registrations.first { $0.id == destination } + } + + static func standard() -> Self { + var registry = Self() + registry.register( + MobileFeatureRegistration( + id: .vault, + title: "Vault", + symbol: "server.rack", + placements: [ + MobileFeaturePlacement(surface: .phoneTab, order: 0), + MobileFeaturePlacement(surface: .padSidebar, order: 0), + ] + ) { app, scene, surface in + AnyView( + MobileVaultView( + app: app, + editingHost: Binding( + get: { scene.editingHost }, + set: { scene.editingHost = $0 } + ), + open: scene.open, + showsMetrics: surface == .phoneTab, + executeSnippet: scene.execute + )) + } + ) + registry.register( + MobileFeatureRegistration( + id: .metrics, + title: "Metrics", + symbol: "gauge.with.dots.needle.33percent", + placements: [MobileFeaturePlacement(surface: .padSidebar, order: 1)] + ) { app, _, _ in + AnyView(MobileMetricsView(app: app)) + } + ) + registry.register( + MobileFeatureRegistration( + id: .workspaces, + title: "Workspaces", + symbol: "terminal", + placements: [ + MobileFeaturePlacement(surface: .phoneTab, order: 1), + MobileFeaturePlacement(surface: .padSidebar, order: 2), + ] + ) { app, scene, _ in + AnyView(MobileWorkspaceListView(app: app, scene: scene)) + } + ) + registry.register( + MobileFeatureRegistration( + id: .assistant, + title: "Assistant", + symbol: "sparkles", + placements: [ + MobileFeaturePlacement(surface: .phoneTab, order: 2), + MobileFeaturePlacement(surface: .padSidebar, order: 3), + ] + ) { app, scene, _ in + AnyView( + MobileAssistantView( + app: app, + model: app.assistant, + libraryRoute: Binding( + get: { scene.assistantLibraryRoute }, + set: { scene.assistantLibraryRoute = $0 } + ) + )) + } + ) + registry.register( + MobileFeatureRegistration( + id: .settings, + title: "Settings", + symbol: "gearshape", + placements: [ + MobileFeaturePlacement(surface: .phoneTab, order: 3), + MobileFeaturePlacement(surface: .padSidebar, order: 4), + ] + ) { app, _, _ in + AnyView(MobileSettingsView(app: app)) + } + ) + return registry + } +} diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 668e799e..8056e1e2 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -1,9 +1,9 @@ import SwiftUI -import UIKit /// iPhone has four destinations. iPad retains the sidebar with individual library routes. struct MobileRootView: View { let app: MobileAppModel + private let features = MobileFeatureRegistry.standard() @Environment(\.scenePhase) private var scenePhase @Environment(\.mobileInterfaceIdiom) private var interfaceIdiom @State private var scene: MobileSceneModel @@ -17,34 +17,26 @@ struct MobileRootView: View { Group { if interfaceIdiom != .pad { TabView(selection: $scene.selectedTab) { - NavigationStack { vault } - .tabItem { Label("Vault", systemImage: "server.rack") }.tag(MobileTab.vault) - NavigationStack { workspaceList } - .tabItem { Label("Workspaces", systemImage: "terminal") }.tag(MobileTab.workspaces) - NavigationStack { assistant } - .tabItem { Label("Assistant", systemImage: "sparkles") }.tag(MobileTab.assistant) - NavigationStack { settings } - .tabItem { Label("Settings", systemImage: "gearshape") }.tag(MobileTab.settings) + ForEach(features.registrations(on: .phoneTab)) { feature in + NavigationStack { + feature.makeView(app: app, scene: scene, surface: .phoneTab) + } + .tabItem { Label(feature.title, systemImage: feature.symbol) } + .tag(feature.id) + } } } else { NavigationSplitView { List(selection: $scene.selectedDestination) { - ForEach(MobileDestination.allCases) { destination in - NavigationLink(value: destination) { - Label(destination.title, systemImage: destination.symbol) + ForEach(features.registrations(on: .padSidebar)) { feature in + NavigationLink(value: feature.id) { + Label(feature.title, systemImage: feature.symbol) } } } } detail: { NavigationStack { - switch scene.selectedDestination { - case .vault: sidebarVault - case .assistant: assistant - case .settings: settings - case .metrics: metrics - case .workspaces: workspaceList - default: sidebarVault - } + selectedFeature.makeView(app: app, scene: scene, surface: .padSidebar) } } } @@ -89,125 +81,9 @@ struct MobileRootView: View { } - private var vault: some View { - MobileVaultView( - app: app, editingHost: $scene.editingHost, - open: scene.open, executeSnippet: scene.execute) - } - - private var sidebarVault: some View { - MobileVaultView( - app: app, editingHost: $scene.editingHost, - open: scene.open, showsMetrics: false, - executeSnippet: scene.execute) - } - - private var workspaceList: some View { - List { - Section { - NavigationLink { - MobileTransfersView(transfers: app.transfers, hosts: app.library) - } label: { - Label("Transfers", systemImage: "arrow.up.arrow.down") - } - } - Section("Workspaces") { workspaceRows } - } - .navigationTitle("Workspaces") - .toolbar { - MobileWorkspaceToolbar( - hasWorkspaces: !app.workspaces.isEmpty, - closeAll: scene.closeAllWorkspaces, - connect: scene.openConnectionPicker) - } - .navigationDestination(item: $scene.workspaceID) { id in - if let workspace = app.workspaces.first(where: { $0.id == id }) { - MobileWorkspaceView( - library: app.library, pro: app.pro, commandGenerator: app.commandGenerator, - workspace: workspace, workspaces: app.workspaces, - selectWorkspace: scene.selectWorkspace, - renameWorkspace: scene.beginWorkspaceRename, - openSameDirectory: scene.openSameDirectory, - closeWorkspace: scene.closeWorkspace - ) - } - } - .sheet(isPresented: $scene.isConnectionPickerPresented) { - NavigationStack { - MobileConnectionPicker( - library: app.library, - connect: { hostID, kind in - scene.isConnectionPickerPresented = false - scene.open(hostID: hostID, kind: kind) - }, - cancel: { scene.isConnectionPickerPresented = false }) - } - .mobileSheetPresentation(compact: true) - } - } - - @ViewBuilder private var workspaceRows: some View { - if app.workspaces.isEmpty { - ContentUnavailableView( - "No Workspaces", systemImage: "terminal", - description: Text("Open a host from Vault to start a workspace.") - ) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - } - ForEach(app.workspaces) { workspace in - Button { - scene.selectWorkspace(workspace.id) - } label: { - HStack(spacing: 12) { - ZStack(alignment: .bottomTrailing) { - Image(systemName: workspace.descriptor.kind.symbol) - .foregroundStyle(.tint) - .frame(width: 28, height: 28) - Circle() - .fill(workspaceStatusTint(workspace)) - .frame(width: 8, height: 8) - .overlay { Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 1.5) } - } - VStack(alignment: .leading, spacing: 2) { - Text(workspace.descriptor.title).foregroundStyle(.primary).lineLimit(1) - Text(workspace.status).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(.rect) - } - .buttonStyle(.plain) - .swipeActions { - Button("Close", systemImage: "xmark", role: .destructive) { scene.closeWorkspace(workspace.id) } - } - .contextMenu { - Button("Rename", systemImage: "pencil") { scene.beginWorkspaceRename(workspace) } - Divider() - Button("Close", systemImage: "xmark", role: .destructive) { scene.closeWorkspace(workspace.id) } - } - } - } - - private func workspaceStatusTint(_ workspace: MobileWorkspace) -> Color { - if workspace.connected { return .green } - if workspace.busy || workspace.reconnectState != nil { return .orange } - return .secondary - } - - private var assistant: some View { - MobileAssistantView( - app: app, model: app.assistant, - libraryRoute: $scene.assistantLibraryRoute) - } - - private var metrics: some View { - MobileMetricsView(app: app) - } - - private var settings: some View { - MobileSettingsView(app: app) + private var selectedFeature: MobileFeatureRegistration { + features.registration(for: scene.selectedDestination ?? .vault) + ?? features.registration(for: .vault)! } } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index 041e7aad..835b71f0 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -26,23 +26,15 @@ final class MobileSceneModel { self.app = app } - var selectedTab: MobileTab { + var selectedTab: MobileDestination { get { switch selectedDestination { - case .workspaces: .workspaces - case .assistant: .assistant - case .settings: .settings + case .workspaces, .assistant, .settings: + selectedDestination ?? .vault case .vault, .metrics, nil: .vault } } - set { - switch newValue { - case .vault: selectedDestination = .vault - case .workspaces: selectedDestination = .workspaces - case .assistant: selectedDestination = .assistant - case .settings: selectedDestination = .settings - } - } + set { selectedDestination = newValue } } var activeWorkspace: MobileWorkspace? { @@ -76,7 +68,7 @@ final class MobileSceneModel { editingHost = Host(name: "", host: "") } - func open(hostID: Host.ID, kind: MobileWorkspaceDescriptor.Kind) { + func open(hostID: Host.ID, kind: ConnectionHistoryEntry.Kind) { guard let id = app.open(hostID: hostID, kind: kind) else { return } selectWorkspace(id) } diff --git a/TFI/Application/Agent/MobileAssistantModel.swift b/TFI/Application/Agent/MobileAssistantModel.swift index 41213abe..6670a2b2 100644 --- a/TFI/Application/Agent/MobileAssistantModel.swift +++ b/TFI/Application/Agent/MobileAssistantModel.swift @@ -3,7 +3,6 @@ import Foundation import Observation import TermindApplication import TermindDomain -import TermindInfrastructure import TermindRemoteInfrastructure /// Application-owned runs, with navigation and drafts independent of each run. @@ -21,6 +20,7 @@ final class MobileAssistantModel { private let execution: AssistantExecutionController private let connections: AgentConnectionPool private let background: AgentBackgroundCommands + private let attachmentLoader: ContextAttachmentLoading private var suspensionTask: Task? private var loaded = false private(set) var isSuspending = false @@ -31,41 +31,24 @@ final class MobileAssistantModel { init( configuration: AIConfigurationModel, repository: any AgentRunPersisting, - hosts: HostLibraryModel, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel, + hostServices: AgentHostServices, metrics: any MetricsSnapshotReading, skills: SkillLibraryModel, authorize: @escaping @MainActor () -> Bool, - speech: SpeechTranscriptionCoordinator? = nil + speech: SpeechTranscriptionCoordinator, + attachmentLoader: ContextAttachmentLoading ) { self.authorize = authorize self.configuration = configuration self.repository = repository self.skills = skills + self.speech = speech + self.attachmentLoader = attachmentLoader library = AssistantLibraryModel(store: store) operations = AssistantConversationOperations(repository: repository) - let audio = IOSSpeechAudioInput() - let coordinator = - speech ?? SpeechTranscriptionCoordinator(service: AppleSpeechTranscriptionService(audioInput: audio)) - self.speech = coordinator - audio.onInterruption = { [weak coordinator] in coordinator?.cancelAll() } - let resolver = AgentHostResolver(store: hosts) - let connections = AgentConnectionPool( - configuration: { try await resolver.configuration(for: $0) }, - routeFingerprint: { try await resolver.routeFingerprint(for: $0) }, - openExec: { request in - let session = SSHExecSession() - try await session.connect(using: request) - return session - }, - openSFTP: { request in - let client = SFTPClient() - try await client.connect( - using: request, progress: nil, - connectionFailure: nil - ) - return client - }) + let resolver = hostServices.resolver + let connections = hostServices.connections + let authentication = hostServices.authentication + let background = hostServices.background self.connections = connections - let authentication = AgentSSHAuthenticationRegistry(resolver: resolver) - let background = AgentBackgroundCommands() self.background = background let titles = ConversationTitleController(ai: configuration) execution = AssistantExecutionController( @@ -92,7 +75,7 @@ final class MobileAssistantModel { } } - private func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = .live } + private func configure(_ conversation: ConversationModel) { conversation.attachmentLoader = attachmentLoader } func select(_ conversation: ConversationModel) { speech.cancelAll() diff --git a/TFI/Application/FeatureModels/MobileHostPickerModel.swift b/TFI/Application/FeatureModels/MobileHostPickerModel.swift new file mode 100644 index 00000000..d7078d67 --- /dev/null +++ b/TFI/Application/FeatureModels/MobileHostPickerModel.swift @@ -0,0 +1,34 @@ +import Observation +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +@MainActor +@Observable +final class MobileHostPickerModel { + var query = "" + + private let library: HostLibraryModel + + init(library: HostLibraryModel) { self.library = library } + + var isLibraryEmpty: Bool { library.hosts.isEmpty } + var hosts: [Host] { library.searchHosts(query) } + + func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } +} + +@MainActor +@Observable +final class MobileConnectionPickerModel { + var kind = ConnectionHistoryEntry.Kind.ssh + + private let connect: (Host.ID, ConnectionHistoryEntry.Kind) -> Void + + init(connect: @escaping (Host.ID, ConnectionHistoryEntry.Kind) -> Void) { + self.connect = connect + } + + func select(_ host: Host) { connect(host.id, kind) } +} diff --git a/TFI/Application/FeatureModels/MobileSnippetOperations.swift b/TFI/Application/FeatureModels/MobileSnippetOperations.swift new file mode 100644 index 00000000..75b4b03c --- /dev/null +++ b/TFI/Application/FeatureModels/MobileSnippetOperations.swift @@ -0,0 +1,23 @@ +import TermindDomain + +import struct TermindDomain.Host + +nonisolated enum MobileSnippetExecutionDecision: Equatable { + case chooseHosts + case confirmDestructive([Host.ID]) + case execute([Host.ID]) +} + +nonisolated enum MobileSnippetOperations { + static func executionDecision( + for snippet: Snippet, + hostIDs: [Host.ID], + requiresHostSelection: Bool + ) -> MobileSnippetExecutionDecision { + if requiresHostSelection || hostIDs.isEmpty { return .chooseHosts } + if CommandRiskClassifier.assess(snippet.body).requiresSecondConfirmation { + return .confirmDestructive(hostIDs) + } + return .execute(hostIDs) + } +} diff --git a/TFI/Application/Metrics/MobileMetricsModel.swift b/TFI/Application/Metrics/MobileMetricsModel.swift index 75c8ca34..ee473111 100644 --- a/TFI/Application/Metrics/MobileMetricsModel.swift +++ b/TFI/Application/Metrics/MobileMetricsModel.swift @@ -2,7 +2,6 @@ import Foundation import Observation import TermindApplication import TermindDomain -import TermindRemoteInfrastructure /// The mobile shell delegates collection, authentication and retries to the desktop's shared model. @MainActor @@ -14,19 +13,19 @@ final class MobileMetricsModel { var servers: [ServerMetric] { core.servers } var selections: [MetricsSelection] { core.selections } - init(preferences: any MetricsConfigurationStoring, hosts: any RemoteSessionHostStore) { + init( + preferences: any MetricsConfigurationStoring, + hosts: any RemoteSessionHostStore, + services: MobileMetricsServices + ) { self.hosts = hosts let limit = limit core = ServerMetricsModel( hosts: hosts, preferences: preferences, - makeSampler: { SSHMetricsSampler(session: SSHExecSession()) }, + makeSampler: services.makeSampler, cardLimit: { limit.maximum }) } - convenience init(defaults: UserDefaults?, hosts: any RemoteSessionHostStore) { - self.init(preferences: MobileMetricsPreferences(defaults: defaults), hosts: hosts) - } - func add(_ id: UUID, pro: Bool) -> Bool { limit.maximum = pro ? nil : ServerMetricsModel.freeCardLimit guard let host = hosts.host(id) else { return false } @@ -42,21 +41,3 @@ final class MobileMetricsModel { } @MainActor private final class MobileMetricsLimit { var maximum: Int? = 3 } - -@MainActor private final class MobileMetricsPreferences: MetricsConfigurationStoring { - private let defaults: UserDefaults? - private var memory: [MetricsSelection] = [] - - init(defaults: UserDefaults?) { self.defaults = defaults } - - func load() -> [MetricsSelection] { - defaults?.data(forKey: "mobile.metrics.v1").flatMap { - try? JSONDecoder().decode([MetricsSelection].self, from: $0) - } ?? memory - } - - func save(_ values: [MetricsSelection]) { - memory = values - if let data = try? JSONEncoder().encode(values) { defaults?.set(data, forKey: "mobile.metrics.v1") } - } -} diff --git a/TFI/Application/Metrics/MobileMetricsServices.swift b/TFI/Application/Metrics/MobileMetricsServices.swift new file mode 100644 index 00000000..45c2fd18 --- /dev/null +++ b/TFI/Application/Metrics/MobileMetricsServices.swift @@ -0,0 +1,9 @@ +import TermindApplication + +struct MobileMetricsServices { + let makeSampler: @Sendable () -> any MetricsSampling + + static var testing: MobileMetricsServices { + MobileMetricsServices(makeSampler: { preconditionFailure("Tests must not start live Metrics sampling") }) + } +} diff --git a/TFI/Features/Workspaces/MobileWorkspaceDescriptor.swift b/TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift similarity index 63% rename from TFI/Features/Workspaces/MobileWorkspaceDescriptor.swift rename to TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift index 7cdab0f8..868d1691 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceDescriptor.swift +++ b/TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift @@ -1,21 +1,17 @@ import Foundation +import TermindDomain nonisolated struct MobileWorkspaceDescriptor: Hashable, Identifiable, Sendable { - enum Kind: String, CaseIterable, Sendable { - case ssh, sftp - var symbol: String { self == .ssh ? "terminal" : "folder" } - } - var id = UUID() var hostID: UUID var title: String - var kind: Kind + var kind: ConnectionHistoryEntry.Kind var destinationURL: URL { WorkspaceActivityAttributes.destinationURL(for: id) } } nonisolated struct MobileWorkspaceDirectoryLaunch: Equatable, Sendable { var hostID: UUID - var kind: MobileWorkspaceDescriptor.Kind + var kind: ConnectionHistoryEntry.Kind var path: String } diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index 02f23a1e..a48dde76 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -22,6 +22,7 @@ struct MobileAssistantComposer: View { @State private var contextDetails = false @State private var isSlashPanelDismissed = false @State private var slashPanelHeight: CGFloat = 0 + private let slashRegistry = ChatSlashCommandRegistry.standard() @FocusState private var localFocus: Bool @Environment(\.locale) private var locale private var composer: ConversationComposerState { conversation.composer } @@ -500,109 +501,16 @@ struct MobileAssistantComposer: View { ) } - private var slashModelChoices: [(argument: String, provider: ModelProvider, model: AIModel)] { - model.configuration.enabledModelProviders.flatMap { provider in - provider.assistantModels.map { item in - ( - ChatSlashCommand.argumentID( - scope: provider.id.uuidString.lowercased(), value: item.id - ), - provider, - item - ) - } - } - } - var availableCommands: [ChatSlashCommand] { - var commands: [ChatSlashCommand] = [] - if !conversation.isEmpty { - commands.append( - ChatSlashCommand( - id: "new", displayName: localized("New chat"), - summary: localized("Start a new conversation"), icon: .symbol("plus.bubble"), - isShownOnMobile: false - )) - } - commands.append( - ChatSlashCommand( - id: "plan", displayName: localized("Plan mode"), - summary: conversation.isPlanning - ? localized("Stop planning and act") - : localized("Research and propose before acting"), - icon: .symbol("lightbulb.max"), argumentHint: localized("what to plan") - )) - if !conversation.isEmpty { - commands.append( - ChatSlashCommand( - id: "compact", displayName: localized("Compact"), - summary: localized("Summarize older turns to free context"), icon: .contextRing, - argumentHint: localized("what to emphasize"), isShownOnMobile: true - )) - } - if reasoningResolution?.supported.contains(where: { $0 != .off }) == true { - commands.append( - ChatSlashCommand( - id: "reasoning", displayName: localized("Reasoning"), - summary: localized("How hard the model thinks"), icon: .symbol("brain"), - argumentHint: localized("effort"), isShownOnMobile: false - )) - } - if !slashModelChoices.isEmpty { - commands.append( - ChatSlashCommand( - id: "model", displayName: localized("Model"), - summary: localized("Switch the model for this conversation"), icon: .symbol("sparkles"), - argumentHint: localized("model"), isShownOnMobile: false - )) - } - if !model.configuration.mcpServers.isEmpty { - commands.append( - ChatSlashCommand( - id: "mcp", displayName: localized("MCP"), - summary: localized("Enable or disable MCP servers"), icon: .symbol("server.rack"), - argumentHint: localized("server"), isShownOnMobile: false - )) - } - if !model.skills.enabledSkills.isEmpty { - commands.append( - ChatSlashCommand( - id: "skill", displayName: localized("Skill"), - summary: localized("Use an installed skill"), icon: .symbol("book.closed"), - argumentHint: localized("skill") - )) - } - return commands.filter(\.isShownOnMobile).sorted { lhs, rhs in - let order = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) - return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending - } + slashRegistry.commands(in: slashContext) } private func slashOptions(_ command: ChatSlashCommand) -> [ChatSlashCommand.Option] { - switch command.id { - case "model": - return slashModelChoices.map { choice in - .init(id: choice.argument, label: choice.model.label, detail: choice.provider.name) - } - case "reasoning": - return reasoningResolution?.supported.map { - .init(id: $0.rawValue, label: $0.displayName) - } ?? [] - case "skill": - return model.skills.enabledSkills.map { - .init(id: $0.effectiveName, label: $0.displayTitle, detail: $0.summary) - } - case "mcp": - return model.configuration.mcpServers.map { - .init(id: $0.id.uuidString.lowercased(), label: $0.name) - } - default: return [] - } + slashRegistry.options(for: command, in: slashContext) } private var slashCompletion: ChatSlashCommandCompletion { - ChatSlashCommandCompletion.rows( - draft: composer.draft, commands: availableCommands, options: slashOptions) + slashRegistry.completion(for: composer.draft, in: slashContext) } private var contextShare: Double? { @@ -611,39 +519,11 @@ struct MobileAssistantComposer: View { } private func slashTrailingText(_ row: ChatSlashCommandCompletion.Row) -> String? { - switch row { - case .command(let command) where command.id == "reasoning": - return effectiveReasoningEffort.displayName - case .command(let command) where command.id == "model": - return resolved?.model.label ?? localized("No model") - case .option(let command, let option) where command.id == "mcp": - guard let id = UUID(uuidString: option.id), let server = model.configuration.mcpServer(id) else { - return nil - } - return server.isEnabled ? localized("Enabled") : localized("Disabled") - case .option(let command, let option) where command.id == "skill": - return option.id - default: - return nil - } + slashRegistry.trailingText(for: row, in: slashContext) } private func isCurrentSlashOption(_ row: ChatSlashCommandCompletion.Row) -> Bool { - guard case .option(let command, let option) = row else { return false } - switch command.id { - case "reasoning": - return option.id == effectiveReasoningEffort.rawValue - case "model": - guard let resolved else { return false } - return option.id - == ChatSlashCommand.argumentID( - scope: resolved.provider.id.uuidString.lowercased(), value: resolved.model.id - ) - case "skill": - return option.id == composer.armedSkillName - default: - return false - } + slashRegistry.isCurrent(row, in: slashContext) } private func localized(_ resource: String.LocalizationValue) -> String { @@ -686,74 +566,22 @@ struct MobileAssistantComposer: View { } @discardableResult private func executeSlash() -> Bool { - guard let command = ChatSlashCommandInput.parse(composer.draft), - availableCommands.contains(where: { $0.id == command.name.lowercased() }) - else { return false } - let argument: String - if case .argument(_, _, let text) = command { argument = text } else { argument = "" } - switch command.name.lowercased() { - case "new": - composer.draft = command.leadingText - model.newConversation() - case "plan": - if argument.isEmpty { - conversation.isPlanning.toggle() - composer.draft = command.leadingText - } else { - conversation.isPlanning = true - composer.draft = command.leadingText + argument - if command.leadingText.isEmpty { model.send() } - } - case "compact": - guard !conversation.isStreaming, !conversation.isCompacting else { return true } - composer.draft = command.leadingText - Task { await model.compact(conversation, instructions: argument) } - case "mcp": - if let id = UUID(uuidString: argument), let server = model.configuration.mcpServer(id), - pro.require(.remoteMCP) - { - model.configuration.setServerEnabled(!server.isEnabled, for: id) - composer.draft = command.leadingText - } else if argument.isEmpty { - composer.draft = command.replacingCommand(with: "/mcp ") - } - case "reasoning": - if let effort = ReasoningEffort(rawValue: argument.lowercased()), - let resolved = model.configuration.resolve(conversation: conversation.model), - ModelCapabilityResolver.reasoning(model: resolved.model, provider: resolved.provider).supported - .contains(effort) - { - conversation.reasoningEffort = effort - composer.draft = command.leadingText - } else if argument.isEmpty { - composer.draft = command.replacingCommand(with: "/reasoning ") - } - case "model": - for provider in model.configuration.enabledModelProviders { - if let item = provider.assistantModels.first(where: { - ChatSlashCommand.argumentID( - scope: provider.id.uuidString.lowercased(), value: $0.id - ) == argument - }) { - model.selectModel(.init(modelProviderID: provider.id, modelID: item.id)) - composer.draft = command.leadingText - return true - } - } - if argument.isEmpty { composer.draft = command.replacingCommand(with: "/model ") } - case "skill": - if let choice = command.selection, - model.skills.enabledSkills.contains(where: { $0.effectiveName == choice.name }) - { - composer.armedSkillName = choice.name - composer.draft = command.leadingText + choice.remainder - if command.leadingText.isEmpty, !choice.remainder.isEmpty { model.send() } - } else if argument.isEmpty { - composer.draft = command.replacingCommand(with: "/skill ") - } - default: break - } - return true + slashRegistry.perform(draft: composer.draft, in: slashContext) + } + + private var slashContext: ChatSlashCommandContext { + ChatSlashCommandContext( + platform: .mobile, + conversation: conversation, + configuration: model.configuration, + skills: model.skills, + compact: { instructions in + Task { await model.compact(conversation, instructions: instructions) } + }, + send: model.send, + authorizeRemoteMCP: { pro.require(.remoteMCP) }, + localized: localized + ) } private func normalizeReasoningEffort() { diff --git a/TFI/Features/HostChooser/MobileConnectionPicker.swift b/TFI/Features/HostChooser/MobileConnectionPicker.swift index 99023c04..6493eb28 100644 --- a/TFI/Features/HostChooser/MobileConnectionPicker.swift +++ b/TFI/Features/HostChooser/MobileConnectionPicker.swift @@ -1,4 +1,3 @@ -import Observation import SwiftUI import TermindApplication import TermindDomain @@ -34,7 +33,7 @@ struct MobileConnectionPicker: View { init( library: HostLibraryModel, - connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void, + connect: @escaping (Host.ID, ConnectionHistoryEntry.Kind) -> Void, cancel: @escaping () -> Void ) { self.library = library @@ -52,8 +51,8 @@ struct MobileConnectionPicker: View { ) { Section { Picker("Connection Protocol", selection: $model.kind) { - Text("SSH").tag(MobileWorkspaceDescriptor.Kind.ssh) - Text("SFTP").tag(MobileWorkspaceDescriptor.Kind.sftp) + Text("SSH").tag(ConnectionHistoryEntry.Kind.ssh) + Text("SFTP").tag(ConnectionHistoryEntry.Kind.sftp) } .pickerStyle(.segmented) } @@ -169,31 +168,3 @@ extension MobileHostPicker where Header == EmptyView { header: { EmptyView() }) } } - -@MainActor -@Observable -final class MobileHostPickerModel { - var query = "" - - private let library: HostLibraryModel - - init(library: HostLibraryModel) { self.library = library } - - var isLibraryEmpty: Bool { library.hosts.isEmpty } - - var hosts: [Host] { library.searchHosts(query) } - - func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } -} - -@MainActor -@Observable -final class MobileConnectionPickerModel { - var kind = MobileWorkspaceDescriptor.Kind.ssh - - private let connect: (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void - - init(connect: @escaping (Host.ID, MobileWorkspaceDescriptor.Kind) -> Void) { self.connect = connect } - - func select(_ host: Host) { connect(host.id, kind) } -} diff --git a/TFI/Features/SFTP/MobileSFTPItemType.swift b/TFI/Features/SFTP/MobileSFTPItemType.swift deleted file mode 100644 index f2a07627..00000000 --- a/TFI/Features/SFTP/MobileSFTPItemType.swift +++ /dev/null @@ -1,5 +0,0 @@ -import UniformTypeIdentifiers - -nonisolated extension UTType { - static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items") -} diff --git a/TFI/Features/Settings/MobileModelFields.swift b/TFI/Features/Settings/MobileModelFields.swift index d50658cb..47ee0bab 100644 --- a/TFI/Features/Settings/MobileModelFields.swift +++ b/TFI/Features/Settings/MobileModelFields.swift @@ -1,5 +1,6 @@ import AgentKit import SwiftUI +import TermindDomain struct MobileModelFields: View { @Binding var model: AIModel @@ -100,9 +101,7 @@ struct MobileModelFields: View { isOn: Binding( get: { model.reasoningProfile?.mapping(for: effort).isSupported ?? false }, set: { enabled in - let count = model.reasoningProfile?.levels.values.filter(\.isSupported).count ?? 0 - guard enabled || count > 1 else { return } - update(effort) { $0.isSupported = enabled } + _ = model.setReasoningEffort(effort, isSupported: enabled) } ) ) @@ -114,10 +113,10 @@ struct MobileModelFields: View { text: Binding( get: { model.reasoningProfile?.mapping(for: effort).wireValue ?? "" }, set: { value in - update(effort) { - $0.wireValue = - value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value - } + model.setReasoningWireValue( + value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value, + for: effort + ) } ) ) @@ -130,13 +129,6 @@ struct MobileModelFields: View { } } - private func update(_ effort: ReasoningEffort, change: (inout ReasoningLevelMapping) -> Void) { - var profile = model.reasoningProfile ?? ReasoningProfile() - var mapping = profile.mapping(for: effort) - change(&mapping) - profile.levels[effort] = mapping - model.reasoningProfile = profile - } } private struct MobileCheckboxToggleStyle: ToggleStyle { diff --git a/TFI/Features/Settings/MobileSyncSettingsView.swift b/TFI/Features/Settings/MobileSyncSettingsView.swift index 6dc0b68b..3c9c5c5a 100644 --- a/TFI/Features/Settings/MobileSyncSettingsView.swift +++ b/TFI/Features/Settings/MobileSyncSettingsView.swift @@ -1,6 +1,7 @@ import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation struct MobileSyncSettingsView: View { let app: MobileAppModel @@ -57,27 +58,14 @@ struct MobileSyncSettingsView: View { -> some View { if let value { + VaultSyncStatusLabel(status: value) switch value { - case .checkingAccount: Label("Checking iCloud…", systemImage: "icloud") - case .paused: Label("Paused", systemImage: "pause.circle") - case .unavailable(let message): Text(message) case .accountChangeConfirmationRequired: - Text("Waiting for confirmation") Button("Merge with This iCloud Account", systemImage: "icloud", action: merge) - case .syncing(let count): Text("Syncing \(count) changes…") - case .upToDate(let date): - HStack { - Label("Up to date", systemImage: "checkmark.icloud") - Spacer(minLength: 8) - if let date { - Text(date, format: .dateTime.year().month().day().hour().minute()) - .font(.caption).foregroundStyle(.secondary).multilineTextAlignment(.trailing) - } - } - .accessibilityElement(children: .combine) - case .failed(let message): - Text(message).foregroundStyle(.red) + case .failed: Button("Retry", systemImage: "arrow.clockwise", action: retry) + default: + EmptyView() } } } diff --git a/TFI/Features/Shell/MobileNavigation.swift b/TFI/Features/Shell/MobileNavigation.swift index f5c94d41..77035694 100644 --- a/TFI/Features/Shell/MobileNavigation.swift +++ b/TFI/Features/Shell/MobileNavigation.swift @@ -1,31 +1,5 @@ -import Foundation - nonisolated enum MobileDestination: String, CaseIterable, Identifiable { case vault, metrics, workspaces, assistant, settings var id: Self { self } - - var title: String { - switch self { - case .vault: String(localized: "Vault") - case .assistant: String(localized: "Assistant") - case .workspaces: String(localized: "Workspaces") - case .metrics: String(localized: "Metrics") - case .settings: String(localized: "Settings") - } - } - - var symbol: String { - switch self { - case .vault: "server.rack" - case .assistant: "sparkles" - case .workspaces: "terminal" - case .metrics: "gauge.with.dots.needle.33percent" - case .settings: "gearshape" - } - } -} - -nonisolated enum MobileTab: String, CaseIterable { - case vault, workspaces, assistant, settings } diff --git a/TFI/Features/Snippets/MobileSnippetsView.swift b/TFI/Features/Snippets/MobileSnippetsView.swift index 15bbaadb..f73512f1 100644 --- a/TFI/Features/Snippets/MobileSnippetsView.swift +++ b/TFI/Features/Snippets/MobileSnippetsView.swift @@ -1,50 +1,12 @@ import SwiftUI +import TermindApplication import TermindDomain import struct TermindDomain.Host -nonisolated enum MobileSnippetExecutionDecision: Equatable { - case chooseHosts - case confirmDestructive([Host.ID]) - case execute([Host.ID]) -} - -nonisolated enum MobileSnippetOperations { - static func duplicate(_ snippet: Snippet) -> Snippet { - var copy = snippet - copy.id = UUID() - copy.title = String(localized: "\(snippet.title) Copy") - copy.sortIndex = 0 - return copy - } - - static func executionDecision( - for snippet: Snippet, hostIDs: [Host.ID], requiresHostSelection: Bool - ) -> MobileSnippetExecutionDecision { - if requiresHostSelection || hostIDs.isEmpty { return .chooseHosts } - if CommandRiskClassifier.assess(snippet.body).requiresSecondConfirmation { - return .confirmDestructive(hostIDs) - } - return .execute(hostIDs) - } -} - struct MobileSnippetsView: View { @Environment(\.locale) private var locale - private enum SectionID: Hashable { - case results - case package(SnippetPackage.ID) - case ungrouped - } - - private struct SnippetSection: Identifiable { - let id: SectionID - let title: String - let snippets: [Snippet] - let isExpanded: Bool? - } - private struct SnippetEditorRequest: Identifiable { let id = UUID() let snippetID: Snippet.ID? @@ -88,40 +50,8 @@ struct MobileSnippetsView: View { _query = State(initialValue: initialQuery) } - private var sections: [SnippetSection] { - if !query.isEmpty { - let matches = app.snippets.searchSnippets(query) - return matches.isEmpty - ? [] - : [ - SnippetSection( - id: .results, title: String(localized: "Results"), snippets: matches, - isExpanded: nil) - ] - } - - var result: [SnippetSection] = [] - func append(_ package: SnippetPackage, prefix: String) { - let path = prefix.isEmpty ? package.name : "\(prefix) / \(package.name)" - result.append( - SnippetSection( - id: .package(package.id), title: path, - snippets: app.snippets.snippets(in: package.id), isExpanded: package.isExpanded)) - guard package.isExpanded else { return } - for child in app.snippets.childSnippetPackages(of: package.id) { - append(child, prefix: path) - } - } - for package in app.snippets.rootSnippetPackages { append(package, prefix: "") } - - let ungrouped = app.snippets.snippets(in: nil) - if !ungrouped.isEmpty || !app.snippets.snippetPackages.isEmpty { - result.append( - SnippetSection( - id: .ungrouped, title: String(localized: "Ungrouped"), snippets: ungrouped, - isExpanded: app.snippets.isSnippetsUngroupedExpanded)) - } - return result + private var sections: [SnippetLibrarySection] { + SnippetLibrarySectionLayout.sections(library: app.snippets, query: query) } var body: some View { @@ -262,7 +192,7 @@ struct MobileSnippetsView: View { } } - private func sectionHeader(_ section: SnippetSection) -> some View { + private func sectionHeader(_ section: SnippetLibrarySection) -> some View { HStack(spacing: 8) { if let isExpanded = section.isExpanded { Button { @@ -363,7 +293,7 @@ struct MobileSnippetsView: View { } } - private func toggle(_ section: SnippetSection, expanded: Bool) { + private func toggle(_ section: SnippetLibrarySection, expanded: Bool) { switch section.id { case .package(let id): app.snippets.setSnippetPackageExpanded(expanded, for: id) case .ungrouped: app.snippets.setSnippetsUngroupedExpanded(expanded) @@ -377,7 +307,7 @@ struct MobileSnippetsView: View { private func duplicate(_ id: Snippet.ID) { guard let snippet = snippet(id) else { return } - let copy = MobileSnippetOperations.duplicate(snippet) + let copy = snippet.duplicated() app.snippets.upsert(copy) reportStorageError() selectedSnippetID = copy.id diff --git a/TFI/Features/Terminal/MobileCommandHistoryView.swift b/TFI/Features/Terminal/MobileCommandHistoryView.swift index f16ffb21..7228679c 100644 --- a/TFI/Features/Terminal/MobileCommandHistoryView.swift +++ b/TFI/Features/Terminal/MobileCommandHistoryView.swift @@ -1,5 +1,6 @@ import SwiftUI import TermindDomain +import TermindPresentation import UIKit struct MobileCommandHistoryView: View { @@ -86,7 +87,7 @@ struct MobileCommandHistoryView: View { .font(.body.monospaced()) .lineLimit(2) .textSelection(.enabled) - Text(MobileTerminalHistoryPresentation.subtitle(for: entry, locale: locale)) + Text(CommandHistoryPresentation.subtitle(for: entry, locale: locale)) .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) diff --git a/TFI/Features/Terminal/MobileTerminalListPresentation.swift b/TFI/Features/Terminal/MobileTerminalListPresentation.swift index db341672..2596266b 100644 --- a/TFI/Features/Terminal/MobileTerminalListPresentation.swift +++ b/TFI/Features/Terminal/MobileTerminalListPresentation.swift @@ -33,25 +33,3 @@ struct MobileTerminalRiskIndicator: View { } } } - -enum MobileTerminalHistoryPresentation { - static func subtitle(for entry: CommandHistoryEntry, locale: Locale) -> String { - var parts = [ - entry.executedAt.formatted( - .relative(presentation: .numeric).locale(locale) - ) - ] - if let duration = entry.duration, let formatted = formattedDuration(duration) { - parts.append(formatted) - } - return parts.joined(separator: " · ") - } - - private static func formattedDuration(_ duration: TimeInterval) -> String? { - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .abbreviated - formatter.maximumUnitCount = 2 - formatter.allowedUnits = duration < 60 ? [.second] : [.hour, .minute, .second] - return formatter.string(from: max(0, duration)) - } -} diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 42a93c05..8192bce1 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -5,49 +5,6 @@ import TermindPresentation import struct TermindDomain.Host -enum MobileHostSectionID: Hashable { - case group(HostGroup.ID) - case ungrouped -} - -struct MobileHostSection: Identifiable { - let id: MobileHostSectionID - let title: String - let hosts: [Host] - let isExpanded: Bool -} - -enum MobileHostSectionLayout { - static func sections(library: HostLibraryModel) -> [MobileHostSection] { - var result: [MobileHostSection] = [] - - func append(_ group: HostGroup, prefix: String) { - let title = prefix.isEmpty ? group.name : "\(prefix) / \(group.name)" - result.append( - MobileHostSection( - id: .group(group.id), title: title, hosts: library.hosts(in: group.id), - isExpanded: group.isExpanded)) - guard group.isExpanded else { return } - for child in library.childGroups(of: group.id) { - append(child, prefix: title) - } - } - - for group in library.rootGroups { - append(group, prefix: "") - } - - let ungrouped = library.hosts(in: nil) - if !ungrouped.isEmpty || !library.groups.isEmpty { - result.append( - MobileHostSection( - id: .ungrouped, title: String(localized: "Ungrouped"), hosts: ungrouped, - isExpanded: library.isUngroupedExpanded)) - } - return result - } -} - private struct MobileVaultLinkGroup: View { @ViewBuilder var content: Content @@ -127,7 +84,7 @@ struct MobileVaultView: View { @Environment(\.locale) private var locale let app: MobileAppModel - let open: (UUID, MobileWorkspaceDescriptor.Kind) -> Void + let open: (UUID, ConnectionHistoryEntry.Kind) -> Void let showsMetrics: Bool let executeSnippet: (Snippet, [Host.ID]) -> Void @Binding var editingHost: Host? @@ -139,7 +96,7 @@ struct MobileVaultView: View { init( app: MobileAppModel, editingHost: Binding, - open: @escaping (UUID, MobileWorkspaceDescriptor.Kind) -> Void, + open: @escaping (UUID, ConnectionHistoryEntry.Kind) -> Void, showsMetrics: Bool = true, executeSnippet: @escaping (Snippet, [Host.ID]) -> Void = { _, _ in } ) { @@ -162,7 +119,7 @@ struct MobileVaultView: View { private var matchesKeychainPage: Bool { pageTitle("Keychain").localizedCaseInsensitiveContains(query) } private var matchesSnippetsPage: Bool { pageTitle("Snippets").localizedCaseInsensitiveContains(query) } private var matchesKnownHostsPage: Bool { pageTitle("Known Hosts").localizedCaseInsensitiveContains(query) } - private var hostSections: [MobileHostSection] { MobileHostSectionLayout.sections(library: app.library) } + private var hostSections: [HostLibrarySection] { HostLibrarySectionLayout.sections(library: app.library) } private var keychainSearchLinks: [SearchLink] { var result: [SearchLink] = [] @@ -253,7 +210,7 @@ struct MobileVaultView: View { ForEach(hostSections) { section in VStack(alignment: .leading, spacing: 12) { hostSectionHeader(section) - if section.isExpanded { + if section.isExpanded != false { MobileCardGrid { ForEach(section.hosts) { row($0) } } @@ -407,7 +364,7 @@ struct MobileVaultView: View { } } - private func hostSectionHeader(_ section: MobileHostSection) -> some View { + private func hostSectionHeader(_ section: HostLibrarySection) -> some View { HStack(spacing: 8) { Button { toggle(section) @@ -415,7 +372,7 @@ struct MobileVaultView: View { HStack(spacing: 8) { Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) - .rotationEffect(.degrees(section.isExpanded ? 90 : 0)) + .rotationEffect(.degrees(section.isExpanded == true ? 90 : 0)) Text(section.title).font(.headline) } .contentShape(.rect) @@ -428,10 +385,12 @@ struct MobileVaultView: View { .accessibilityElement(children: .contain) } - private func toggle(_ section: MobileHostSection) { + private func toggle(_ section: HostLibrarySection) { + guard let isExpanded = section.isExpanded else { return } switch section.id { - case .group(let id): app.library.setExpanded(!section.isExpanded, for: id) - case .ungrouped: app.library.setUngroupedExpanded(!section.isExpanded) + case .group(let id): app.library.setExpanded(!isExpanded, for: id) + case .ungrouped: app.library.setUngroupedExpanded(!isExpanded) + case .results: break } } diff --git a/TFI/Features/Workspaces/MobileWorkspaceListView.swift b/TFI/Features/Workspaces/MobileWorkspaceListView.swift new file mode 100644 index 00000000..2514d17f --- /dev/null +++ b/TFI/Features/Workspaces/MobileWorkspaceListView.swift @@ -0,0 +1,111 @@ +import SwiftUI +import UIKit + +/// The registered Workspaces destination. +struct MobileWorkspaceListView: View { + let app: MobileAppModel + @Bindable var scene: MobileSceneModel + + var body: some View { + List { + Section { + NavigationLink { + MobileTransfersView(transfers: app.transfers, hosts: app.library) + } label: { + Label("Transfers", systemImage: "arrow.up.arrow.down") + } + } + Section("Workspaces") { workspaceRows } + } + .navigationTitle("Workspaces") + .toolbar { + MobileWorkspaceToolbar( + hasWorkspaces: !app.workspaces.isEmpty, + closeAll: scene.closeAllWorkspaces, + connect: scene.openConnectionPicker + ) + } + .navigationDestination(item: $scene.workspaceID) { id in + if let workspace = app.workspaces.first(where: { $0.id == id }) { + MobileWorkspaceView( + library: app.library, pro: app.pro, commandGenerator: app.commandGenerator, + workspace: workspace, workspaces: app.workspaces, + selectWorkspace: scene.selectWorkspace, + renameWorkspace: scene.beginWorkspaceRename, + openSameDirectory: scene.openSameDirectory, + closeWorkspace: scene.closeWorkspace + ) + } + } + .sheet(isPresented: $scene.isConnectionPickerPresented) { + NavigationStack { + MobileConnectionPicker( + library: app.library, + connect: { hostID, kind in + scene.isConnectionPickerPresented = false + scene.open(hostID: hostID, kind: kind) + }, + cancel: { scene.isConnectionPickerPresented = false } + ) + } + .mobileSheetPresentation(compact: true) + } + } + + @ViewBuilder + private var workspaceRows: some View { + if app.workspaces.isEmpty { + ContentUnavailableView( + "No Workspaces", systemImage: "terminal", + description: Text("Open a host from Vault to start a workspace.") + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + ForEach(app.workspaces) { workspace in + Button { + scene.selectWorkspace(workspace.id) + } label: { + HStack(spacing: 12) { + ZStack(alignment: .bottomTrailing) { + Image(systemName: workspace.descriptor.kind.symbol) + .foregroundStyle(.tint) + .frame(width: 28, height: 28) + Circle() + .fill(statusTint(for: workspace)) + .frame(width: 8, height: 8) + .overlay { Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 1.5) } + } + VStack(alignment: .leading, spacing: 2) { + Text(workspace.descriptor.title).foregroundStyle(.primary).lineLimit(1) + Text(workspace.status).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(.rect) + } + .buttonStyle(.plain) + .swipeActions { + Button("Close", systemImage: "xmark", role: .destructive) { + scene.closeWorkspace(workspace.id) + } + } + .contextMenu { + Button("Rename", systemImage: "pencil") { + scene.beginWorkspaceRename(workspace) + } + Divider() + Button("Close", systemImage: "xmark", role: .destructive) { + scene.closeWorkspace(workspace.id) + } + } + } + } + + private func statusTint(for workspace: MobileWorkspace) -> Color { + if workspace.connected { return .green } + if workspace.busy || workspace.reconnectState != nil { return .orange } + return .secondary + } +} diff --git a/TFI/Infrastructure/SSH/MobileMetricsServices+Live.swift b/TFI/Infrastructure/SSH/MobileMetricsServices+Live.swift new file mode 100644 index 00000000..eafe774f --- /dev/null +++ b/TFI/Infrastructure/SSH/MobileMetricsServices+Live.swift @@ -0,0 +1,7 @@ +import TermindRemoteInfrastructure + +extension MobileMetricsServices { + static var live: MobileMetricsServices { + MobileMetricsServices(makeSampler: { SSHMetricsSampler(session: SSHExecSession()) }) + } +} diff --git a/TFITests/MobileAISettingsToolbarTests.swift b/TFITests/MobileAISettingsToolbarTests.swift index 0b2b31e7..2db61e4f 100644 --- a/TFITests/MobileAISettingsToolbarTests.swift +++ b/TFITests/MobileAISettingsToolbarTests.swift @@ -40,7 +40,7 @@ struct MobileAISettingsToolbarTests { let host = TermindDomain.Host(name: "Test", host: "example.invalid") app.library.upsert(host) - for kind in [MobileWorkspaceDescriptor.Kind.ssh, .sftp] { + for kind in [ConnectionHistoryEntry.Kind.ssh, .sftp] { let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind), hosts: app.library) @@ -66,7 +66,7 @@ struct MobileAISettingsToolbarTests { let host = TermindDomain.Host(name: "Test", host: "example.invalid") app.library.upsert(host) - for kind in [MobileWorkspaceDescriptor.Kind.ssh, .sftp] { + for kind in [ConnectionHistoryEntry.Kind.ssh, .sftp] { let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: host.id, title: host.name, kind: kind), hosts: app.library) diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 5a3f1539..713987a3 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -14,11 +14,18 @@ import UIKit @Suite @MainActor struct MobileAppTests { + private final class MetricsPreferences: MetricsConfigurationStoring { + var values: [MetricsSelection] = [] + func load() -> [MetricsSelection] { values } + func save(_ selections: [MetricsSelection]) { values = selections } + } + @Test func startupUsesTheSharedSchemaAndEmptyRepository() throws { let app = try MobileAppModel(inMemory: true) #expect(try app.hosts.snapshot().value == .empty) #expect(Set(app.container.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name))) - #expect(MobileTab.allCases == [.vault, .workspaces, .assistant, .settings]) + let features = MobileFeatureRegistry.standard() + #expect(features.registrations(on: .phoneTab).map(\.id) == [.vault, .workspaces, .assistant, .settings]) } @Test func scenesCanUseTheSameAppRepository() throws { @@ -104,7 +111,8 @@ struct MobileWorkspaceTests { let library = HostLibraryModel() let hosts = (0..<5).map { TermindDomain.Host(name: "Host \($0)", host: "example.invalid") } for host in hosts { library.upsert(host) } - let metrics = MobileMetricsModel(defaults: nil, hosts: library) + let metrics = MobileMetricsModel( + preferences: MetricsPreferences(), hosts: library, services: .testing) metrics.pause() #expect(metrics.add(hosts[0].id, pro: false)) #expect(metrics.add(hosts[0].id, pro: false)) diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index 21c0116f..c9f16c25 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -231,7 +231,7 @@ struct MobileAssistantTests { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } ) - #expect(!commands.contains { !$0.isShownOnMobile }) + #expect(commands.allSatisfy { $0.platforms.contains(.mobile) }) #expect(!first.summary.isEmpty) } diff --git a/TFITests/MobileCardTests.swift b/TFITests/MobileCardTests.swift index 617c11ee..5728a26d 100644 --- a/TFITests/MobileCardTests.swift +++ b/TFITests/MobileCardTests.swift @@ -61,7 +61,7 @@ struct MobileCardTests { app.library.upsert(childHost) app.library.upsert(ungroupedHost) - let expanded = MobileHostSectionLayout.sections(library: app.library) + let expanded = HostLibrarySectionLayout.sections(library: app.library) #expect( expanded.map(\.id) == [.group(root.id), .group(child.id), .group(empty.id), .ungrouped]) @@ -69,13 +69,13 @@ struct MobileCardTests { #expect(expanded.first(where: { $0.id == .group(empty.id) })?.hosts.isEmpty == true) app.library.setExpanded(false, for: root.id) - let collapsed = MobileHostSectionLayout.sections(library: app.library) + let collapsed = HostLibrarySectionLayout.sections(library: app.library) #expect(collapsed.map(\.id) == [.group(root.id), .group(empty.id), .ungrouped]) #expect(app.library.searchHosts("Child Node").map(\.id) == [childHost.id]) app.library.setUngroupedExpanded(false) #expect( - MobileHostSectionLayout.sections(library: app.library) + HostLibrarySectionLayout.sections(library: app.library) .first(where: { $0.id == .ungrouped })?.isExpanded == false) let stored = try HostPersistence(context: ModelContext(app.container)).load() diff --git a/TFITests/MobileConnectionPickerTests.swift b/TFITests/MobileConnectionPickerTests.swift index e112a8c4..10cd2641 100644 --- a/TFITests/MobileConnectionPickerTests.swift +++ b/TFITests/MobileConnectionPickerTests.swift @@ -11,7 +11,7 @@ import struct TermindDomain.Host struct MobileConnectionPickerTests { @Test func pickerDefaultsToSSHFiltersHostsAndReturnsTheSelectedProtocol() throws { let app = try MobileAppModel(inMemory: true) - var selections: [(Host.ID, MobileWorkspaceDescriptor.Kind)] = [] + var selections: [(Host.ID, ConnectionHistoryEntry.Kind)] = [] let hostModel = MobileHostPickerModel(library: app.library) let connectionModel = MobileConnectionPickerModel { selections.append(($0, $1)) } diff --git a/TFITests/MobileDropTests.swift b/TFITests/MobileDropTests.swift index 56adb1c0..549b63fd 100644 --- a/TFITests/MobileDropTests.swift +++ b/TFITests/MobileDropTests.swift @@ -2,6 +2,7 @@ import Foundation import GhosttyTerminal import SwiftUI import TermindApplication +import TermindDomain import Testing import UIKit import UniformTypeIdentifiers @@ -164,8 +165,8 @@ nonisolated private func failedFileProvider() -> NSItemProvider { @Suite(.serialized) @MainActor struct MobileWorkspaceDropTests { - @Test(arguments: MobileWorkspaceDescriptor.Kind.allCases) - func hostedWorkspacesRenderTheOverlayAcrossTheirContent(kind: MobileWorkspaceDescriptor.Kind) async throws { + @Test(arguments: ConnectionHistoryEntry.Kind.allCases) + func hostedWorkspacesRenderTheOverlayAcrossTheirContent(kind: ConnectionHistoryEntry.Kind) async throws { let app = try MobileAppModel(inMemory: true) let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor(hostID: UUID(), title: "Drop Test", kind: kind), hosts: app.library) diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 4876311b..3f5a5848 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -17,8 +17,9 @@ struct MobileNavigationParityTests { contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantConversation.swift"), encoding: .utf8) let workRows = try String( contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantWorkRow.swift"), encoding: .utf8) - let mobileRoot = try String( - contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8) + let workspaceList = try String( + contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceListView.swift"), + encoding: .utf8) let currentConversationMenu = try #require( assistant.components(separatedBy: ".toolbar {").dropFirst().first? .components(separatedBy: ".navigationDestination").first) @@ -32,8 +33,8 @@ struct MobileNavigationParityTests { assistant.components(separatedBy: "private func conversationRow").last? .components(separatedBy: "private func select").first) let workspaceRows = try #require( - mobileRoot.components(separatedBy: "@ViewBuilder private var workspaceRows").last? - .components(separatedBy: "private func workspaceStatusTint").first) + workspaceList.components(separatedBy: "private var workspaceRows").last? + .components(separatedBy: "private func statusTint").first) #expect(assistant.contains(".toolbarTitleMenu")) #expect(assistant.contains("@Binding var libraryRoute: MobileAssistantLibraryRoute?")) @@ -263,7 +264,7 @@ struct MobileNavigationParityTests { #expect(composer.contains("get: { resolvedModelRef }")) #expect(!composer.contains("Text(\"Automatic\")")) #expect(!composer.contains("private var controls:")) - #expect(composer.contains("commands.filter(\\.isShownOnMobile).sorted")) + #expect(composer.contains("slashRegistry.commands(in: slashContext)")) #expect(composer.contains(".onSubmit(submitFromKeyboard)")) #expect(composer.contains(".onKeyPress(.escape")) let badges = try #require( @@ -316,8 +317,12 @@ struct MobileNavigationParityTests { } @Test func vaultReplacesWelcomeAndMetricsRemainsBelowItOnIPad() { - #expect(MobileTab.allCases == [.vault, .workspaces, .assistant, .settings]) - #expect(MobileDestination.allCases == [.vault, .metrics, .workspaces, .assistant, .settings]) + let features = MobileFeatureRegistry.standard() + #expect(features.registrations(on: .phoneTab).map(\.id) == [.vault, .workspaces, .assistant, .settings]) + #expect( + features.registrations(on: .padSidebar).map(\.id) + == [.vault, .metrics, .workspaces, .assistant, .settings] + ) } @Test func aiSettingsUseThreeNativeManagementPages() { @@ -337,14 +342,12 @@ struct MobileNavigationParityTests { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() - let rootSource = try String( - contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8) + let registrySource = try String( + contentsOf: root.appending(path: "TFI/App/MobileFeatureRegistry.swift"), encoding: .utf8) let vaultSource = try String( contentsOf: root.appending(path: "TFI/Features/Vault/MobileVaultView.swift"), encoding: .utf8) - #expect(rootSource.contains("NavigationStack { vault }")) - #expect(rootSource.contains("case .vault: sidebarVault")) - #expect(rootSource.contains("open: scene.open, showsMetrics: false")) + #expect(registrySource.contains("showsMetrics: surface == .phoneTab")) #expect(vaultSource.contains("guard showsMetrics else { return [] }")) #expect(vaultSource.contains("if showsMetrics {")) } @@ -426,11 +429,11 @@ struct MobileNavigationParityTests { .deletingLastPathComponent() .deletingLastPathComponent() let workspaceList = try String( - contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8) + contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceListView.swift"), + encoding: .utf8) #expect(workspaceList.contains(".contextMenu {")) - #expect( - workspaceList.contains( - "Button(\"Close\", systemImage: \"xmark\", role: .destructive) { scene.closeWorkspace(workspace.id) }")) + #expect(workspaceList.contains("Button(\"Close\", systemImage: \"xmark\", role: .destructive)")) + #expect(workspaceList.contains("scene.closeWorkspace(workspace.id)")) let toolbar = try String( contentsOf: root.appending(path: "TFI/Features/HostChooser/MobileConnectionPicker.swift"), encoding: .utf8) @@ -530,13 +533,14 @@ struct MobileNavigationParityTests { contentsOf: root.appending(path: "TFI/Features/Vault/MobileVaultView.swift"), encoding: .utf8) #expect(vault.contains("MobileCardPage {")) #expect(!vault.contains("List {")) - #expect(vault.contains("app.library.setExpanded(!section.isExpanded, for: id)")) - #expect(vault.contains("app.library.setUngroupedExpanded(!section.isExpanded)")) + #expect(vault.contains("app.library.setExpanded(!isExpanded, for: id)")) + #expect(vault.contains("app.library.setUngroupedExpanded(!isExpanded)")) - let rootView = try String( - contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8) - #expect(rootView.contains("ContentUnavailableView(\n \"No Workspaces\"")) - #expect(rootView.contains(".buttonStyle(.plain)")) + let workspaceList = try String( + contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceListView.swift"), + encoding: .utf8) + #expect(workspaceList.contains("ContentUnavailableView(\n \"No Workspaces\"")) + #expect(workspaceList.contains(".buttonStyle(.plain)")) let pro = try String( contentsOf: root.appending(path: "TFI/Features/Commerce/MobileProView.swift"), encoding: .utf8) @@ -610,6 +614,9 @@ struct MobileNavigationParityTests { .deletingLastPathComponent() let workspace = try String( contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceView.swift"), encoding: .utf8) + let workspaceList = try String( + contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceListView.swift"), + encoding: .utf8) let rootView = try String( contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8) let sameDirectoryAction = try #require( @@ -625,7 +632,7 @@ struct MobileNavigationParityTests { workspace.components( separatedBy: "Button(\"Rename\", systemImage: \"pencil\") { renameWorkspace(workspace) }" ).count == 3) - #expect(rootView.contains("Button(\"Rename\", systemImage: \"pencil\")")) + #expect(workspaceList.contains("Button(\"Rename\", systemImage: \"pencil\")")) #expect(rootView.contains("MobileWorkspaceRenameView(workspace: workspace)")) } diff --git a/TFITests/MobileSnippetTests.swift b/TFITests/MobileSnippetTests.swift index b59797fa..20ab92ba 100644 --- a/TFITests/MobileSnippetTests.swift +++ b/TFITests/MobileSnippetTests.swift @@ -98,7 +98,7 @@ struct MobileSnippetTests { title: "Deploy", body: "deploy --safe", packageID: packageID, targetHostIDs: [hostID], sortIndex: 42) - let copy = MobileSnippetOperations.duplicate(original) + let copy = original.duplicated() #expect(copy.id != original.id) #expect(copy.title == "Deploy Copy") diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index ed90c059..2a7df3d3 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -113,7 +113,7 @@ final class AppModel { /// 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 agentBackgroundCommands: AgentBackgroundCommands private let outboxRunner: OutboxRunner? private var windows: [ObjectIdentifier: WeakWindow] = [:] private var deletedAgentConversationIDs = Set() @@ -166,15 +166,11 @@ final class AppModel { connections = connectionCoordinator serialPorts = dependencies.serialPortService agentRunRepository = dependencies.agentRunRepository ?? InMemoryAgentRunRepository() - agentHosts = AgentHostResolver(store: hosts) - let agentHostResolver = agentHosts - agentAuthentication = AgentSSHAuthenticationRegistry(resolver: agentHostResolver) - agentConnections = AgentConnectionPool( - configuration: { try await agentHostResolver.configuration(for: $0) }, - routeFingerprint: { try await agentHostResolver.routeFingerprint(for: $0) }, - openExec: { try await connectionCoordinator.connectExec(using: $0) }, - openSFTP: { try await connectionCoordinator.connectSFTP(using: $0) } - ) + let agentHostServices = AgentHostServices.live(store: hosts, coordinator: connectionCoordinator) + agentHosts = agentHostServices.resolver + agentAuthentication = agentHostServices.authentication + agentConnections = agentHostServices.connections + agentBackgroundCommands = agentHostServices.background hostRepository = dependencies.hostRepository vaultSync = dependencies.vaultSync settingsSync = dependencies.settingsSync @@ -235,36 +231,18 @@ final class AppModel { } } if let outboxRunner { - outboxRunner.register( - RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.closeWorkspaces) { - [weak self] deletion in + outboxRunner.registerHostDeletionHandlers( + repository: hostRepository, + closeWorkspaces: { [weak self] deletion in self?.closeWorkspaces(for: deletion.hostID) - } - ) - outboxRunner.register( - RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.cancelTransfers) { - [weak self] deletion in + }, + cancelTransfers: { [weak self] deletion in self?.sftpTransfers.cancelAll(for: deletion.hostID) - } - ) - outboxRunner.register( - RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.stopForwardings) { - [weak self] deletion in + }, + stopForwardings: { [weak self] deletion in self?.forwarding.remove(deletion.forwardingRuleIDs) } ) - outboxRunner.register( - RepositoryHostDeletionHandler( - handlerID: HostDeletionOutbox.clearReferences, - repository: hostRepository - ) - ) - outboxRunner.register( - RepositoryHostDeletionHandler( - handlerID: HostDeletionOutbox.finalize, - repository: hostRepository - ) - ) outboxRunner.start() } vaultSync?.didApplyRemote = { [weak self] snapshot, keys in @@ -307,33 +285,24 @@ final class AppModel { private func applyRemoteVaultSnapshot( _ snapshot: VaultSnapshot, changedKeys: Set ) { - let changedHostIDs = Set(changedKeys.filter { $0.kind == .host }.map(\.id)) - let changedRuleIDs = Set(changedKeys.filter { $0.kind == .portForwarding }.map(\.id)) - let nextHostIDs = Set(snapshot.hosts.map(\.id)) - - for hostID in changedHostIDs { - let oldHost = hosts.host(hostID) - let newHost = snapshot.hosts.first { $0.id == hostID } - let routeChanged = - oldHost.map { old in - newHost.map { - old.host != $0.host || old.port != $0.port - || old.identityID != $0.identityID || old.jumpHostID != $0.jumpHostID - || old.proxy != $0.proxy - } ?? true - } ?? false - if routeChanged { - hosts.willDeleteHost?(hostID, hosts.portForwardings(for: hostID).map(\.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 oldHost != nil, !nextHostIDs.contains(hostID) { - closeWorkspaces(for: hostID) - sftpTransfers.cancelAll(for: hostID) - commandHistory.clear(for: hostID) - sftpHistory.clear(for: hostID) - connectionHistory.clear(for: hostID) + if change.isRemoved { + closeWorkspaces(for: change.id) + sftpTransfers.cancelAll(for: change.id) + commandHistory.clear(for: change.id) + sftpHistory.clear(for: change.id) + connectionHistory.clear(for: change.id) } } - if !changedRuleIDs.isEmpty { forwarding.suspend(Array(changedRuleIDs)) } + if !plan.forwardingRuleIDs.isEmpty { forwarding.suspend(plan.forwardingRuleIDs) } hosts.replacePersistedSnapshot(snapshot) forwarding.resumeEnabled() } diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index ef0f8eda..9340f3bb 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -466,6 +466,7 @@ struct ComposerView: View { @State private var slashSelection = 0 @State private var slashScrollRequest = 0 @State private var slashMenuHeight: CGFloat = 0 + private let slashRegistry = ChatSlashCommandRegistry.standard() /// Escape closes the palette without clearing the draft, so the draft alone /// cannot decide whether it is open. @State private var isSlashMenuDismissed = false @@ -966,150 +967,29 @@ struct ComposerView: View { // MARK: - Slash commands - /// What this conversation offers. `/reasoning` only where the resolved model - /// has more than one effort to choose between — a command that can only - /// confirm what is already true is a row that wastes the reader's time. + private var slashContext: ChatSlashCommandContext { + ChatSlashCommandContext( + platform: .desktop, + conversation: conversation, + configuration: app.aiConfiguration, + skills: app.skills, + archive: { + window.setAssistantArchived(conversation.id, archived: true) + }, + newConversation: onNewChat, + compact: { instructions in + Task { await app.compact(conversation, instructions: instructions) } + }, + send: sendDraft + ) + } + private var slashCommands: [ChatSlashCommand] { - var commands: [ChatSlashCommand] = [] - if !conversation.isEmpty { - commands.append( - ChatSlashCommand( - id: "archive", displayName: String(localized: "Archive"), - summary: String(localized: "Move this conversation to Archive"), icon: .symbol("archivebox"), - isShownOnMobile: false - )) - } - if !conversation.isEmpty, onNewChat != nil { - commands.append( - ChatSlashCommand( - id: "new", displayName: String(localized: "New chat"), - summary: String(localized: "Start a new conversation"), - icon: .symbol("plus.bubble") - )) - } - commands.append( - ChatSlashCommand( - id: "plan", displayName: String(localized: "Plan mode"), - summary: conversation.isPlanning - ? String(localized: "Stop planning and act") - : String(localized: "Research and propose before acting"), - icon: .symbol(PlanModeChip.symbol), - // Free text, so `/plan why is root at 99%` both enters the mode - // and asks the question. Typing `/plan` alone toggles it. - argumentHint: String(localized: "what to plan") - )) - // Existing conversations always expose Compact, even before enough - // history has accumulated to compact. A blank New chat has nothing it - // could summarize, so the command would only be noise there. - if !conversation.isEmpty { - commands.append( - ChatSlashCommand( - id: "compact", displayName: String(localized: "Compact"), - summary: String(localized: "Summarize older turns to free context"), - icon: .contextRing, - argumentHint: String(localized: "what to emphasize") - )) - } - if supportsThinking { - commands.append( - ChatSlashCommand( - id: "reasoning", displayName: String(localized: "Reasoning"), - summary: String(localized: "How hard the model thinks"), - icon: .symbol("brain"), - argumentHint: String(localized: "effort") - )) - } - if !slashModelChoices.isEmpty { - commands.append( - ChatSlashCommand( - id: "model", displayName: String(localized: "Model"), - summary: String(localized: "Switch the model for this conversation"), - icon: .symbol("sparkles"), - argumentHint: String(localized: "model") - )) - } - if !app.aiConfiguration.mcpServers.isEmpty { - commands.append( - ChatSlashCommand( - id: "mcp", displayName: String(localized: "MCP"), - summary: String(localized: "Enable or disable MCP servers"), - icon: .symbol("server.rack"), - argumentHint: String(localized: "server") - )) - } - // Only where there is something to name. A model normally reaches for a - // skill on its own from the catalog it is shown; this is for the times - // it does not, so it has nothing to offer when nothing is installed. - if !app.skills.enabledSkills.isEmpty { - commands.append( - ChatSlashCommand( - id: "skill", displayName: String(localized: "Skill"), - summary: String(localized: "Use an installed skill"), - icon: .symbol(SkillChip.symbol), - argumentHint: String(localized: "skill") - )) - } - // Only the first page is alphabetical. A command's second page keeps - // its semantic order — especially Reasoning's effort scale and the - // configured order of providers and MCP servers. - return commands.sorted { lhs, rhs in - let order = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) - return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending - } + slashRegistry.commands(in: slashContext) } private var slashOptions: (ChatSlashCommand) -> [ChatSlashCommand.Option] { - { command in - switch command.id { - case "reasoning": - guard let reasoningResolution else { return [] } - return reasoningResolution.supported.map { effort in - ChatSlashCommand.Option(id: effort.rawValue, label: effort.displayName) - } - case "model": - return slashModelChoices.map { choice in - ChatSlashCommand.Option(id: choice.argument, label: choice.model.label) - } - case "mcp": - return app.aiConfiguration.mcpServers.map { server in - ChatSlashCommand.Option( - id: server.id.uuidString.lowercased(), label: server.name - ) - } - case "skill": - return app.skills.enabledSkills.map { skill in - ChatSlashCommand.Option( - id: skill.effectiveName, label: skill.displayTitle - ) - } - default: - return [] - } - } - } - - private var slashModelChoices: - [( - argument: String, provider: ModelProvider, model: AIModel - )] - { - modelProviders.flatMap { provider in - provider.assistantModels.map { model in - ( - argument: ChatSlashCommand.argumentID( - scope: provider.id.uuidString.lowercased(), value: model.id - ), - provider: provider, - model: model - ) - } - } - } - - private func slashModelChoice( - for argument: String - ) -> (argument: String, provider: ModelProvider, model: AIModel)? { - slashModelChoices.first { $0.argument == argument } + { slashRegistry.options(for: $0, in: slashContext) } } private var slashCompletion: ChatSlashCommandCompletion { @@ -1118,9 +998,7 @@ struct ComposerView: View { } private func slashCompletion(for draft: String) -> ChatSlashCommandCompletion { - ChatSlashCommandCompletion.rows( - draft: draft, commands: slashCommands, options: slashOptions - ) + slashRegistry.completion(for: draft, in: slashContext) } /// Opening a single-choice page starts on the value this conversation @@ -1129,23 +1007,7 @@ struct ComposerView: View { private func preferredSlashSelection( in completion: ChatSlashCommandCompletion ) -> Int { - guard case .option(let command, _) = completion.rows.first else { return 0 } - - switch command.id { - case "reasoning": - return completion.selectionIndex( - commandID: command.id, optionID: effectiveReasoningEffort.rawValue - ) - case "model": - guard let resolved else { return 0 } - let optionID = - ChatSlashCommand.argumentID( - scope: resolved.provider.id.uuidString.lowercased(), value: resolved.model.id - ) - return completion.selectionIndex(commandID: command.id, optionID: optionID) - default: - return 0 - } + slashRegistry.preferredSelection(in: completion, context: slashContext) } private func selectPreferredSlashOption(for draft: String) { @@ -1184,30 +1046,7 @@ struct ComposerView: View { } private func slashTrailingText(_ row: ChatSlashCommandCompletion.Row) -> String? { - switch row { - case .command(let command) where command.id == "reasoning": - return effectiveReasoningEffort.displayName - case .option(let command, let option) where command.id == "reasoning": - return option.id == effectiveReasoningEffort.rawValue - ? String(localized: "Current") : nil - case .command(let command) where command.id == "model": - return resolvedModelLabel - case .option(let command, let option) where command.id == "model": - return slashModelChoice(for: option.id)?.provider.name - case .option(let command, let option) where command.id == "mcp": - guard - let id = UUID(uuidString: option.id), - let server = app.aiConfiguration.mcpServer(id) - else { return nil } - return server.isEnabled - ? String(localized: "Enabled") : String(localized: "Disabled") - case .option(let command, let option) where command.id == "skill": - // The model-facing name. Two skills can carry the same title, and - // this is the one the argument actually is. - return option.id - default: - return nil - } + slashRegistry.trailingText(for: row, in: slashContext) } /// Completing a name leaves the command in the field; running it consumes @@ -1250,89 +1089,12 @@ struct ComposerView: View { /// the user said to it. @discardableResult private func runSlashCommand() -> Bool { - guard let input = ChatSlashCommandInput.parse(conversation.composer.draft), - let command = slashCommands.first(where: { $0.id == input.name.lowercased() }) - else { return false } - - switch command.id { - case "archive": - guard !conversation.isEmpty, !conversation.isStreaming, !conversation.isCompacting else { - return false - } - clearSlashDraft(input) - window.setAssistantArchived(conversation.id, archived: true) - return true - case "new": - guard !conversation.isEmpty, let onNewChat else { return false } - clearSlashDraft(input) - onNewChat() - return true - case "plan": - // The one command that runs on its bare name: `/plan` toggles the - // mode, and `/plan ` turns it on and asks. Both are the - // whole action, so neither leaves a half-typed command behind. - let question = argumentText(of: input) - guard question.isEmpty else { - conversation.isPlanning = true - conversation.composer.draft = input.leadingText + question - if input.leadingText.isEmpty { sendDraft() } - return true - } - conversation.isPlanning.toggle() - case "compact": - let instructions = argumentText(of: input) - clearSlashDraft(input) - Task { await app.compact(conversation, instructions: instructions) } - return true - case "reasoning": - guard case .argument(_, _, let argument) = input else { return false } - guard let effort = ReasoningEffort(rawValue: argument.lowercased()), - reasoningResolution?.supported.contains(effort) == true - else { return false } - conversation.reasoningEffort = effort - case "model": - guard case .argument(_, _, let argument) = input, - let choice = slashModelChoice(for: argument) - else { return false } - select(choice.model, on: choice.provider) - case "mcp": - guard case .argument(_, _, let argument) = input, - let id = UUID(uuidString: argument), - let server = app.aiConfiguration.mcpServer(id) - else { return false } - app.aiConfiguration.setServerEnabled(!server.isEnabled, for: id) - case "skill": - // `/skill ` arms it and leaves a chip; `/skill ` - // arms it and asks in the same keystroke — the shape `/plan` already - // has. An unknown name changes nothing and leaves the draft alone, - // as `/model` does, because the alternative is silently sending a - // command as a message. - guard let selection = input.selection, - app.skills.enabledSkills.contains(where: { $0.effectiveName == selection.name }) - else { return false } - conversation.composer.armedSkillName = selection.name - conversation.composer.draft = input.leadingText + selection.remainder - guard selection.remainder.isEmpty else { - if input.leadingText.isEmpty { sendDraft() } - return true - } - return true - default: - return false + let performed = slashRegistry.perform(draft: conversation.composer.draft, in: slashContext) + if performed { + isSlashMenuDismissed = false + slashSelection = 0 } - clearSlashDraft(input) - return true - } - - private func argumentText(of input: ChatSlashCommandInput) -> String { - guard case .argument(_, _, let text) = input else { return "" } - return text.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func clearSlashDraft(_ input: ChatSlashCommandInput) { - conversation.composer.draft = input.leadingText - isSlashMenuDismissed = false - slashSelection = 0 + return performed } /// Accepts what the palette is highlighting, if it is showing anything. diff --git a/TFM/Features/Hosts/HostsPageView.swift b/TFM/Features/Hosts/HostsPageView.swift index 838c1ac4..98b9b0d9 100644 --- a/TFM/Features/Hosts/HostsPageView.swift +++ b/TFM/Features/Hosts/HostsPageView.swift @@ -1,4 +1,5 @@ import SwiftUI +import TermindApplication import TermindDomain import TermindPresentation @@ -49,19 +50,6 @@ final class HostsPageState { /// connects. Nothing outside this page acts on that selection — ⌘T raises the /// host chooser rather than reusing whatever is highlighted here. struct HostsPageView: ManagedCardPage { - nonisolated enum SectionID: Hashable { - case results - case group(HostGroup.ID) - case ungrouped - - var groupID: HostGroup.ID? { - switch self { - case .group(let id): id - case .results, .ungrouped: nil - } - } - } - @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @@ -84,82 +72,23 @@ struct HostsPageView: ManagedCardPage { // MARK: - Sections - private struct Section { - let id: SectionID - let title: String - /// `nil` means the section cannot be folded at all — which is only - /// Results, since folding away what you just searched for is not a thing - /// anyone wants. - var isExpanded: Bool? - let hosts: [Host] - } - private var isFiltering: Bool { !state.query.isEmpty || !state.selectedTags.isEmpty } /// Filtering collapses the grouping into one "Results" section: when you are /// hunting for a name, being told which folder it sits in is noise. Tags /// narrow the same way — and stack with the field, so `prod` plus "web" /// means both. - private var sections: [Section] { - guard !isFiltering else { - let matches = app.hostLibrary.searchHosts(state.query).filter { - $0.hasTags(state.selectedTags) - } - guard !matches.isEmpty else { return [] } - return [Section(id: .results, title: String(localized: "Results"), hosts: matches)] - } - - var result: [Section] = [] - - // Nested groups are flattened into "Parent / Child" headers, so the tree - // reads in a grid that has no room to indent. - // - // Collapsing a group therefore has to stop the recursion, not just hide - // one grid: `Production / Edge` is a sibling row in this list, and - // leaving it behind when Production folds would strand a child header - // under a parent that is no longer showing anything. - // - // Empty groups get a header too, with a count of zero. This is the page - // that creates groups, and one that vanished the moment it was made - // would read as a button that did nothing. - func walk(_ group: HostGroup, prefix: String) { - let title = prefix.isEmpty ? group.name : "\(prefix) / \(group.name)" - result.append( - Section( - id: .group(group.id), title: title, - isExpanded: group.isExpanded, - hosts: app.hostLibrary.hosts(in: group.id) - ) - ) - guard group.isExpanded else { return } - for child in app.hostLibrary.childGroups(of: group.id) { - walk(child, prefix: title) - } - } - for group in app.hostLibrary.rootGroups { - walk(group, prefix: "") - } - - let ungrouped = app.hostLibrary.hosts(in: nil) - if !ungrouped.isEmpty || !app.hostLibrary.groups.isEmpty { - // Foldable like a real group, because on a fleet that is mostly - // unfiled it is the section most worth getting out of the way. Its - // flag has to live on the store instead of on a `HostGroup` — there - // is no group here to hang it on. - result.append( - Section( - id: .ungrouped, title: String(localized: "Ungrouped"), - isExpanded: app.hostLibrary.isUngroupedExpanded, hosts: ungrouped - ) - ) - } - - return result + private var sections: [HostLibrarySection] { + HostLibrarySectionLayout.sections( + library: app.hostLibrary, + query: state.query, + selectedTags: state.selectedTags + ) } // MARK: - Page - var managedSections: [ManagedCardSection] { + var managedSections: [ManagedCardSection] { sections.map { section in ManagedCardSection( id: section.id, title: section.title, items: section.hosts, @@ -173,7 +102,7 @@ struct HostsPageView: ManagedCardPage { ManagedCardSelection(selected: { hostSelection }, update: setHostSelection) } - var managedCapabilities: ManagedCardCapabilities { + var managedCapabilities: ManagedCardCapabilities { ManagedCardCapabilities( selection: .multiple, marqueeSelection: true, @@ -192,7 +121,7 @@ struct HostsPageView: ManagedCardPage { @ViewBuilder func managedHeader( - for section: ManagedCardSection, isDropTarget: Bool + for section: ManagedCardSection, isDropTarget: Bool ) -> some View { if case .group(let groupID) = section.id, let group = app.hostLibrary.group(groupID) { CardSectionHeader( @@ -244,7 +173,7 @@ struct HostsPageView: ManagedCardPage { /// tag filter live now that the dashed tiles and the page toolbar are gone. /// Right-clicking inside a group files the new host there without a second /// decision. - func managedBlankAreaMenu(for section: SectionID?) -> [NativeMenuEntry] { + func managedBlankAreaMenu(for section: HostLibrarySection.ID?) -> [NativeMenuEntry] { let groupID = section?.groupID return [ NativeMenuEntry(title: String(localized: "New Host"), symbol: MenuSymbol.newHost) { diff --git a/TFM/Features/SFTP/SFTPItemType.swift b/TFM/Features/SFTP/SFTPItemType.swift deleted file mode 100644 index f2a07627..00000000 --- a/TFM/Features/SFTP/SFTPItemType.swift +++ /dev/null @@ -1,5 +0,0 @@ -import UniformTypeIdentifiers - -nonisolated extension UTType { - static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items") -} diff --git a/TFM/Features/Settings/ModelListEditor.swift b/TFM/Features/Settings/ModelListEditor.swift index 0aab3dc5..1a176090 100644 --- a/TFM/Features/Settings/ModelListEditor.swift +++ b/TFM/Features/Settings/ModelListEditor.swift @@ -1,5 +1,6 @@ import AgentKit import SwiftUI +import TermindDomain /// A provider's full-width models page: a checkable list and details for the /// selected row. @@ -499,18 +500,15 @@ struct ModelListEditor: View { let mapping = model.reasoningProfile?.mapping(for: effort) ?? ReasoningLevelMapping(isSupported: false) - let supportedCount = model.reasoningProfile?.levels.values.filter(\.isSupported).count ?? 0 - return HStack(spacing: Metrics.Space.s) { Toggle( effort.displayName, isOn: Binding( get: { mapping.isSupported }, set: { enabled in - guard enabled || supportedCount > 1 else { return } - updateReasoningMapping(model, effort: effort) { - $0.isSupported = enabled - } + var updated = model + guard updated.setReasoningEffort(effort, isSupported: enabled) else { return } + app.aiConfiguration.updateModel(updated, in: modelProviderID) } ) ) @@ -522,10 +520,10 @@ struct ModelListEditor: View { text: Binding( get: { mapping.wireValue ?? "" }, set: { value in - updateReasoningMapping(model, effort: effort) { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - $0.wireValue = trimmed.isEmpty ? nil : value - } + var updated = model + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + updated.setReasoningWireValue(trimmed.isEmpty ? nil : value, for: effort) + app.aiConfiguration.updateModel(updated, in: modelProviderID) } ) ) @@ -535,20 +533,6 @@ struct ModelListEditor: View { } } - private func updateReasoningMapping( - _ model: AIModel, - effort: ReasoningEffort, - update: (inout ReasoningLevelMapping) -> Void - ) { - var model = model - var profile = model.reasoningProfile ?? ReasoningProfile() - var mapping = profile.mapping(for: effort) - update(&mapping) - profile.levels[effort] = mapping - model.reasoningProfile = profile - app.aiConfiguration.updateModel(model, in: modelProviderID) - } - private func limitField( _ label: LocalizedStringKey, model: AIModel, @@ -742,14 +726,6 @@ struct ModelListEditor: View { } } -extension AIModel { - fileprivate var withoutReasoningOverride: AIModel { - var copy = self - copy.reasoningProfile = nil - return copy - } -} - // MARK: - Picking what to add /// The list a Fetch proposes. diff --git a/TFM/Features/Settings/SyncSettings.swift b/TFM/Features/Settings/SyncSettings.swift index dd562f07..6c62ef0f 100644 --- a/TFM/Features/Settings/SyncSettings.swift +++ b/TFM/Features/Settings/SyncSettings.swift @@ -1,6 +1,7 @@ import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation struct SyncSettings: View { @Environment(AppModel.self) private var app @@ -82,7 +83,7 @@ private struct SyncSelectionRow: View { .toggleStyle(.checkbox) Spacer() if isSelected, let status { - SyncStatusLabel(status: status) + VaultSyncStatusLabel(status: status) .font(.caption).foregroundStyle(.secondary) } } @@ -98,42 +99,3 @@ private struct SyncSelectionRow: View { } } } - -struct SyncStatusLabel: View { - let status: VaultSyncStatus - @Environment(\.locale) private var locale - - @ViewBuilder - var body: some View { - switch status { - case .checkingAccount: - Label("Checking iCloud…", systemImage: "icloud") - case .paused: - Label("Paused", systemImage: "pause.circle") - case .unavailable(let message): - Label(message, systemImage: "icloud.slash").foregroundStyle(.secondary) - case .accountChangeConfirmationRequired: - Label("Waiting for confirmation", systemImage: "person.crop.circle.badge.exclamationmark") - .foregroundStyle(.orange) - case .syncing(let pendingCount): - Label( - pendingCount == 1 ? "Syncing 1 change…" : "Syncing \(pendingCount) changes…", - systemImage: "arrow.triangle.2.circlepath.icloud" - ) - case .upToDate(let lastSync): - if let lastSync { - let formatted = lastSync.formatted( - Date.FormatStyle(date: .abbreviated, time: .shortened).locale(locale) - ) - Label( - "Up to date · \(formatted)", - systemImage: "checkmark.icloud" - ) - } else { - Label("Up to date", systemImage: "checkmark.icloud") - } - case .failed(let message): - Label(message, systemImage: "exclamationmark.icloud").foregroundStyle(.red) - } - } -} diff --git a/TFM/Features/Sidebar/WindowSidebarView.swift b/TFM/Features/Sidebar/WindowSidebarView.swift index 169f9e78..260c1016 100644 --- a/TFM/Features/Sidebar/WindowSidebarView.swift +++ b/TFM/Features/Sidebar/WindowSidebarView.swift @@ -3,6 +3,7 @@ import ListViewKit import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation import struct TermindDomain.Host @@ -1284,7 +1285,7 @@ private struct CommandHistoryCard: View { symbol: nil, title: entry.command, titleIsMonospaced: true, - subtitle: subtitle, + subtitle: CommandHistoryPresentation.subtitle(for: entry, locale: locale), subtitleIsMonospaced: false, isSelected: isSelected ) { @@ -1312,28 +1313,6 @@ private struct CommandHistoryCard: View { .help(Text(entry.command)) } - private var subtitle: String { - var parts = [ - entry.executedAt.formatted( - .relative(presentation: .numeric).locale(locale) - ) - ] - if let duration = entry.duration, - let formatted = formattedDuration(duration) - { - parts.append(formatted) - } - return parts.joined(separator: " · ") - } - - private func formattedDuration(_ duration: TimeInterval) -> String? { - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .abbreviated - formatter.maximumUnitCount = 2 - formatter.allowedUnits = - duration < 60 ? [.second] : [.hour, .minute, .second] - return formatter.string(from: max(0, duration)) - } } /// A native borderless field whose editor remains transparent inside a Sidebar diff --git a/TFM/Features/Snippets/SnippetsPageView.swift b/TFM/Features/Snippets/SnippetsPageView.swift index 21b6bf44..cc5c1a91 100644 --- a/TFM/Features/Snippets/SnippetsPageView.swift +++ b/TFM/Features/Snippets/SnippetsPageView.swift @@ -42,24 +42,6 @@ final class SnippetsPageState { /// terminal Workspaces. ManagedCardPage owns selection and drag geometry; this /// page supplies only the Package sections and business actions. struct SnippetsPageView: ManagedCardPage { - nonisolated enum SectionID: Hashable { - case results - case package(SnippetPackage.ID) - case ungrouped - - var packageID: SnippetPackage.ID? { - if case .package(let id) = self { return id } - return nil - } - } - - private struct Section { - let id: SectionID - let title: String - var isExpanded: Bool? - let snippets: [Snippet] - } - @Environment(AppModel.self) private var app @Environment(WindowModel.self) private var window @@ -78,51 +60,13 @@ struct SnippetsPageView: ManagedCardPage { private var isFiltering: Bool { !state.query.isEmpty } - private var sections: [Section] { - if isFiltering { - let matches = app.snippets.searchSnippets(state.query) - guard !matches.isEmpty else { return [] } - return [ - Section( - id: .results, title: String(localized: "Results"), - isExpanded: nil, snippets: matches - ) - ] - } - - var result: [Section] = [] - func walk(_ package: SnippetPackage, prefix: String) { - let title = prefix.isEmpty ? package.name : "\(prefix) / \(package.name)" - result.append( - Section( - id: .package(package.id), title: title, - isExpanded: package.isExpanded, - snippets: app.snippets.snippets(in: package.id) - ) - ) - guard package.isExpanded else { return } - for child in app.snippets.childSnippetPackages(of: package.id) { - walk(child, prefix: title) - } - } - for package in app.snippets.rootSnippetPackages { walk(package, prefix: "") } - - let ungrouped = app.snippets.snippets(in: nil) - if !ungrouped.isEmpty || !app.snippets.snippetPackages.isEmpty { - result.append( - Section( - id: .ungrouped, title: String(localized: "Ungrouped"), - isExpanded: app.snippets.isSnippetsUngroupedExpanded, - snippets: ungrouped - ) - ) - } - return result + private var sections: [SnippetLibrarySection] { + SnippetLibrarySectionLayout.sections(library: app.snippets, query: state.query) } // MARK: ManagedCardPage - var managedSections: [ManagedCardSection] { + var managedSections: [ManagedCardSection] { sections.map { ManagedCardSection( id: $0.id, title: $0.title, items: $0.snippets, @@ -136,7 +80,7 @@ struct SnippetsPageView: ManagedCardPage { ManagedCardSelection(selected: { selection }, update: setSelection) } - var managedCapabilities: ManagedCardCapabilities { + var managedCapabilities: ManagedCardCapabilities { ManagedCardCapabilities( selection: .multiple, marqueeSelection: true, @@ -157,7 +101,7 @@ struct SnippetsPageView: ManagedCardPage { @ViewBuilder func managedHeader( - for section: ManagedCardSection, isDropTarget: Bool + for section: ManagedCardSection, isDropTarget: Bool ) -> some View { switch section.id { case .package(let id): @@ -229,7 +173,7 @@ struct SnippetsPageView: ManagedCardPage { /// Right-clicking inside a package files the new snippet or subpackage /// there. Results is a view of matches from everywhere, so it files at the /// root rather than guessing which package the user meant. - func managedBlankAreaMenu(for section: SectionID?) -> [NativeMenuEntry] { + func managedBlankAreaMenu(for section: SnippetLibrarySection.ID?) -> [NativeMenuEntry] { let packageID = section?.packageID return [ NativeMenuEntry( @@ -306,11 +250,7 @@ struct SnippetsPageView: ManagedCardPage { } private func duplicate(_ snippet: Snippet) { - var copy = snippet - copy.id = UUID() - copy.title = String(localized: "\(snippet.title) Copy") - copy.sortIndex = 0 - saveSnippet(copy) + saveSnippet(snippet.duplicated()) } private func savePackage(_ package: SnippetPackage) { diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 9ca6e1d6..738bfef6 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -219,6 +219,38 @@ struct ArchitectureBoundaryTests { try assertNoConcreteImplementations(in: "../TFI/Features") } + @Test + func mobileFeaturesAndLiveServicesEnterThroughRegistries() throws { + let root = try String( + contentsOf: sourceRoot.appending(path: "../TFI/App/MobileRootView.swift"), encoding: .utf8) + let features = try String( + contentsOf: sourceRoot.appending(path: "../TFI/App/MobileFeatureRegistry.swift"), encoding: .utf8) + #expect(root.contains("features.registrations(on: .phoneTab)")) + #expect(root.contains("features.registrations(on: .padSidebar)")) + #expect(!root.contains("switch scene.selectedDestination")) + #expect(features.contains("mutating func register(")) + #expect(features.contains("Duplicate mobile feature registration")) + + let descriptor = try String( + contentsOf: sourceRoot.appending( + path: "../TFI/Application/Workspaces/MobileWorkspaceDescriptor.swift"), encoding: .utf8) + #expect(descriptor.contains("var kind: ConnectionHistoryEntry.Kind")) + #expect(!descriptor.contains("enum Kind")) + + let assistant = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Application/Agent/MobileAssistantModel.swift"), + encoding: .utf8) + let metrics = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Application/Metrics/MobileMetricsModel.swift"), + encoding: .utf8) + for concrete in ["SSHExecSession()", "SFTPClient()"] { + #expect(!assistant.contains(concrete)) + } + for concrete in ["SSHMetricsSampler", "SSHExecSession"] { + #expect(!metrics.contains(concrete)) + } + } + @Test func sessionFeaturesDependOnTheConnectionPort() throws { for path in ["Features/Terminal/SSHSessionModel.swift", "Features/SFTP/SFTPSessionModel.swift"] { diff --git a/TFMTests/ChatSlashCommandTests.swift b/TFMTests/ChatSlashCommandTests.swift index 05baa7eb..48da162f 100644 --- a/TFMTests/ChatSlashCommandTests.swift +++ b/TFMTests/ChatSlashCommandTests.swift @@ -29,14 +29,14 @@ struct ChatSlashCommandTests { ] @Test - func commandsCanOptOutOfTheMobileCatalog() { + func commandsDeclareTheirSupportedPlatforms() { let archive = ChatSlashCommand( id: "archive", displayName: "Archive", summary: "Archive this chat", - icon: .symbol("archivebox"), isShownOnMobile: false + icon: .symbol("archivebox"), platforms: [.desktop] ) - #expect(!archive.isShownOnMobile) - #expect(!commands.contains { !$0.isShownOnMobile }) + #expect(archive.platforms == [.desktop]) + #expect(commands.allSatisfy { $0.platforms == ChatSlashCommand.Platform.allCases }) } private func options(_ command: ChatSlashCommand) -> [ChatSlashCommand.Option] { diff --git a/TFMTests/KeychainOrderTests.swift b/TFMTests/KeychainOrderTests.swift index b7714dbf..bb9f3666 100644 --- a/TFMTests/KeychainOrderTests.swift +++ b/TFMTests/KeychainOrderTests.swift @@ -103,7 +103,7 @@ struct KeychainOrderTests { #expect(fixed.allowsMove(from: .keys, to: .keys)) #expect(!fixed.allowsMove(from: .keys, to: .identities)) - let exchangeable = ManagedCardDropPolicy.anySection + let exchangeable = ManagedCardDropPolicy.anySection #expect( exchangeable.allowsMove( from: .ungrouped, to: .group(UUID())