This commit is contained in:
Kaede Akino committed 2026-09-13 01:30:00 +08:00
1 parent 8f007628d8
commit 3df3bcaf0c
57 files changed
+1761 -1315

No files matched your search

@@ -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> = [
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: {}
)
}
}
@@ -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
}
}
@@ -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
}
}
@@ -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<VaultRecordKey>
) -> 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
}
}
@@ -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
))
}
}
@@ -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<String> = []
) -> [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
}
}
@@ -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
}
}
@@ -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
@@ -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
}
}
@@ -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))
}
}
@@ -0,0 +1,5 @@
import UniformTypeIdentifiers
nonisolated extension UTType {
public static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items")
}
@@ -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)
}
}
}
@@ -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()
)
}
}
+21
View File
@@ -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)
+56 -2
View File
@@ -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.
+7 -3
View File
@@ -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
)
}
}
+41 -39
View File
@@ -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<VaultRecordKey>) {
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?
+166
View File
@@ -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
}
}
+15 -139
View File
@@ -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)!
}
}
+5 -13
View File
@@ -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)
}
@@ -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<Void, Never>?
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()
@@ -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) }
}
@@ -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)
}
}
@@ -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") }
}
}
@@ -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") })
}
}
@@ -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
}
+22 -194
View File
@@ -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() {
@@ -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) }
}
@@ -1,5 +0,0 @@
import UniformTypeIdentifiers
nonisolated extension UTType {
static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items")
}
+6 -14
View File
@@ -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 {
@@ -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()
}
}
}
-26
View File
@@ -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
}
+6 -76
View File
@@ -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
@@ -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)
@@ -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))
}
}
+11 -52
View File
@@ -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<Content: View>: 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<Host?>,
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
}
}
@@ -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
}
}
@@ -0,0 +1,7 @@
import TermindRemoteInfrastructure
extension MobileMetricsServices {
static var live: MobileMetricsServices {
MobileMetricsServices(makeSampler: { SSHMetricsSampler(session: SSHExecSession()) })
}
}
+2 -2
View File
@@ -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)
+10 -2
View File
@@ -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))
+1 -1
View File
@@ -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)
}
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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)) }
+3 -2
View File
@@ -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)
+30 -23
View File
@@ -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)"))
}
+1 -1
View File
@@ -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")
+28 -59
View File
@@ -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<UUID>()
@@ -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<VaultRecordKey>
) {
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()
}
+28 -266
View File
@@ -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 <question>` 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 <name>` arms it and leaves a chip; `/skill <name> <text>`
// 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.
+11 -82
View File
@@ -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<SectionID, Host>] {
var managedSections: [ManagedCardSection<HostLibrarySection.ID, Host>] {
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<SectionID, Host.ID> {
var managedCapabilities: ManagedCardCapabilities<HostLibrarySection.ID, Host.ID> {
ManagedCardCapabilities(
selection: .multiple,
marqueeSelection: true,
@@ -192,7 +121,7 @@ struct HostsPageView: ManagedCardPage {
@ViewBuilder
func managedHeader(
for section: ManagedCardSection<SectionID, Host>, isDropTarget: Bool
for section: ManagedCardSection<HostLibrarySection.ID, Host>, 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) {
-5
View File
@@ -1,5 +0,0 @@
import UniformTypeIdentifiers
nonisolated extension UTType {
static let termindSFTPItems = UTType(exportedAs: "com.akinokaede.termind.sftp-items")
}
+8 -32
View File
@@ -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.
+2 -40
View File
@@ -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)
}
}
}
+2 -23
View File
@@ -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
+7 -67
View File
@@ -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<SectionID, Snippet>] {
var managedSections: [ManagedCardSection<SnippetLibrarySection.ID, Snippet>] {
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<SectionID, Snippet.ID> {
var managedCapabilities: ManagedCardCapabilities<SnippetLibrarySection.ID, Snippet.ID> {
ManagedCardCapabilities(
selection: .multiple,
marqueeSelection: true,
@@ -157,7 +101,7 @@ struct SnippetsPageView: ManagedCardPage {
@ViewBuilder
func managedHeader(
for section: ManagedCardSection<SectionID, Snippet>, isDropTarget: Bool
for section: ManagedCardSection<SnippetLibrarySection.ID, Snippet>, 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) {
+32
View File
@@ -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"] {
+4 -4
View File
@@ -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] {
+1 -1
View File
@@ -103,7 +103,7 @@ struct KeychainOrderTests {
#expect(fixed.allowsMove(from: .keys, to: .keys))
#expect(!fixed.allowsMove(from: .keys, to: .identities))
let exchangeable = ManagedCardDropPolicy<HostsPageView.SectionID>.anySection
let exchangeable = ManagedCardDropPolicy<HostLibrarySection.ID>.anySection
#expect(
exchangeable.allowsMove(
from: .ungrouped, to: .group(UUID())