From f6d4e71451b13d61e9ea76a7cc6aa63e5f2fb8f0 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 12 Sep 2026 22:39:45 +0800 Subject: [PATCH] Bulk Assistant --- ...AssistantTranscriptPresentationTests.swift | 265 ++++ .../ChatMessageTimestampTests.swift | 6 - FrameworkTests/MetricsJourneyTests.swift | 63 + Frameworks/Resources/Localizable.xcstrings | 94 +- .../Agent/AssistantLibraryModel.swift | 13 +- .../Agent/ChatSlashCommand.swift | 92 +- .../Agent/ConversationModel.swift | 20 +- .../Metrics/ServerMetricsModel.swift | 338 +++-- .../AgentToolPresentation.swift | 51 +- .../AssistantContextGauge.swift | 15 +- .../AssistantMessageBody.swift | 20 +- .../AssistantToolDetails.swift | 46 +- ...AssistantTranscriptPresentationState.swift | 143 ++ .../AssistantTranscriptPreview.swift | 53 + .../AssistantTranscriptStyle.swift | 26 + .../ChatMessageTimestampFormatter.swift | 4 +- .../DropTargetOverlay.swift | 113 ++ .../MarkdownSoftBreaks.swift | 5 +- .../TermindMarkdownParser.swift | 189 +++ .../Agent/TermindAgentTools.swift | 5 + Memory/APP_ICON.md | 39 +- Memory/APP_STORE_COMMERCE.md | 122 +- Memory/ARCHITECTURE.md | 1149 +++++------------ Memory/MOBILE_ROLLOUT.md | 272 +--- Memory/REFACTOR.md | 11 + Memory/SETTINGS_SYNC_RELEASE.md | 77 +- TFI/App/MobileRootView.swift | 2 +- .../Agent/MobileAssistantModel.swift | 20 +- .../Transfers/MobileSFTPSessionModel.swift | 6 +- TFI/Features/AI/MobileAssistantComposer.swift | 372 +++--- .../AI/MobileAssistantConversation.swift | 655 ++++++++-- .../AI/MobileAssistantInteraction.swift | 52 +- .../AI/MobileAssistantTranscriptCards.swift | 122 ++ .../AI/MobileAssistantTranscriptStyle.swift | 132 ++ TFI/Features/AI/MobileAssistantWorkRow.swift | 207 +++ TFI/Features/AI/MobileSlashCommandPanel.swift | 71 +- .../Assistant/MobileAssistantView.swift | 189 ++- .../Terminal/MobileTerminalKeyboard.swift | 38 + .../Workspaces/MobileWorkspaceView.swift | 104 +- TFI/Platform/IOSSpeechAudioInput.swift | 29 +- TFI/UI/Components/MobileDropPayload.swift | 159 +++ TFI/UI/Components/MobileNativeList.swift | 240 +++- TFITests/MobileAppTests.swift | 12 + TFITests/MobileAssistantRenderingTests.swift | 212 ++- TFITests/MobileAssistantTests.swift | 311 ++++- TFITests/MobileAssistantTranscriptTests.swift | 253 ++++ TFITests/MobileNavigationParityTests.swift | 287 +++- TFITests/MobileSFTPSessionTests.swift | 39 + TFM/Design/DesignTokens.swift | 22 +- TFM/Features/AI/ConversationViews.swift | 61 +- TFM/Features/AI/MarkdownBlockListView.swift | 127 +- TFM/Features/AI/SlashCommandMenuView.swift | 9 +- TFM/Features/AI/TranscriptListView.swift | 108 +- TFM/Features/AI/TranscriptNativeRows.swift | 53 +- TFM/Features/SFTP/SFTPBrowserView.swift | 7 +- TFM/Features/SSHImport/SSHImportView.swift | 1 + .../Workspaces/WorkspaceContentView.swift | 1 + TFM/UI/Components/DropTargetOverlay.swift | 70 - TFMTests/AgentPersistenceTests.swift | 6 +- TFMTests/AgentRuntimeTests.swift | 2 +- TFMTests/AgentToolPresentationTests.swift | 45 +- TFMTests/ArchitectureBoundaryTests.swift | 51 + TFMTests/ChatSlashCommandTests.swift | 80 +- TFMTests/ListViewMigrationTests.swift | 8 + TFMTests/TranscriptListViewTests.swift | 73 +- TFMTests/TranscriptRenderingTests.swift | 79 ++ 66 files changed, 5180 insertions(+), 2366 deletions(-) create mode 100644 FrameworkTests/AssistantTranscriptPresentationTests.swift rename {TFMTests => FrameworkTests}/ChatMessageTimestampTests.swift (95%) rename {TFM/Features/AI => Frameworks/TermindPresentation}/AgentToolPresentation.swift (97%) create mode 100644 Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift create mode 100644 Frameworks/TermindPresentation/AssistantTranscriptPreview.swift create mode 100644 Frameworks/TermindPresentation/AssistantTranscriptStyle.swift rename TFM/Features/AI/MessageViews.swift => Frameworks/TermindPresentation/ChatMessageTimestampFormatter.swift (88%) create mode 100644 Frameworks/TermindPresentation/DropTargetOverlay.swift rename {TFM/Features/AI/Markdown => Frameworks/TermindPresentation}/MarkdownSoftBreaks.swift (98%) create mode 100644 Frameworks/TermindPresentation/TermindMarkdownParser.swift create mode 100644 TFI/Features/AI/MobileAssistantTranscriptCards.swift create mode 100644 TFI/Features/AI/MobileAssistantTranscriptStyle.swift create mode 100644 TFI/Features/AI/MobileAssistantWorkRow.swift create mode 100644 TFI/UI/Components/MobileDropPayload.swift create mode 100644 TFITests/MobileAssistantTranscriptTests.swift delete mode 100644 TFM/UI/Components/DropTargetOverlay.swift create mode 100644 TFMTests/TranscriptRenderingTests.swift diff --git a/FrameworkTests/AssistantTranscriptPresentationTests.swift b/FrameworkTests/AssistantTranscriptPresentationTests.swift new file mode 100644 index 00000000..b03c91d7 --- /dev/null +++ b/FrameworkTests/AssistantTranscriptPresentationTests.swift @@ -0,0 +1,265 @@ +import AgentKit +import Foundation +import TermindApplication +import TermindPresentation +import Testing + +@MainActor +@Suite +struct AssistantTranscriptPresentationTests { + @Test func toolBatchAndFirstToolWithTheSameSourceIDHaveIndependentDisclosures() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + let batch = AssistantTranscriptDisclosureID.toolBatch(id) + let tool = AssistantTranscriptDisclosureID.tool(id) + + #expect(!state.isExpanded(batch)) + #expect(!state.isExpanded(tool)) + + state.toggleDisclosure(batch) + #expect(state.isExpanded(batch)) + #expect(!state.isExpanded(tool)) + + state.toggleDisclosure(tool) + #expect(state.isExpanded(batch)) + #expect(state.isExpanded(tool)) + + state.toggleDisclosure(tool) + #expect(state.isExpanded(batch)) + #expect(!state.isExpanded(tool)) + + state.toggleDisclosure(batch) + state.toggleDisclosure(batch) + #expect(state.isExpanded(batch)) + #expect(!state.isExpanded(tool)) + #expect(state.layoutRevision(for: [id]) > 0) + } + + @Test func aSingleToolCanDefaultOpenWithoutBecomingStoredBatchState() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + + #expect(state.isExpanded(.tool(id), default: true)) + #expect(!state.isExpanded(.toolBatch(id))) + #expect(!state.isExpanded(.tool(id))) + + state.toggleDisclosure(.tool(id), default: true) + #expect(!state.isExpanded(.tool(id), default: true)) + #expect(!state.isExpanded(.toolBatch(id))) + } + + @Test func changingFromOneToolToABatchUsesBatchDefaultsAndKeepsExplicitToolChoices() { + let id = UUID() + let untouched = AssistantTranscriptPresentationState() + + #expect(untouched.isExpanded(.tool(id), default: true)) + #expect(!untouched.isExpanded(.toolBatch(id))) + #expect(!untouched.isExpanded(.tool(id))) + + let explicitlyExpanded = AssistantTranscriptPresentationState() + explicitlyExpanded.toggleDisclosure(.tool(id), default: true) + explicitlyExpanded.toggleDisclosure(.tool(id), default: true) + #expect(explicitlyExpanded.isExpanded(.tool(id))) + #expect(!explicitlyExpanded.isExpanded(.toolBatch(id))) + } + + @Test func completionKeepsVisibleWorkAndPruningDropsOldConversationState() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + let old = work(id: id, state: .running) + let completed = work(id: id, state: .completed) + state.prepareWorkCompletionTransitions(from: [old], to: [completed], visibleIDs: [id]) + #expect(state.isExpanded(.work(id))) + #expect(state.collapseWorkOutsideViewport(visibleIDs: [id]).isEmpty) + #expect(state.collapseWorkOutsideViewport(visibleIDs: []) == [id]) + #expect(!state.isExpanded(.work(id))) + + state.toggleDisclosure(.work(id)) + state.prune(validTimelineIDs: []) + #expect(!state.readerDidToggle(.work(id))) + #expect(state.layoutRevision(for: [id]) == 0) + } + + @Test func historicalAndManuallyFoldedWorkDoNotAcquirePendingCollapses() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + let old = work(id: id, state: .running) + let completed = work(id: id, state: .completed) + state.prepareWorkCompletionTransitions(from: [], to: [completed], visibleIDs: [id]) + #expect(!state.isExpanded(.work(id))) + state.toggleDisclosure(.work(id), default: true) + state.prepareWorkCompletionTransitions(from: [old], to: [completed], visibleIDs: [id]) + #expect(!state.isExpanded(.work(id))) + #expect(!state.hasPendingAutoCollapse(id)) + let afterAnswer = work(id: id, state: .completed, afterAnswer: true) + let fresh = AssistantTranscriptPresentationState() + fresh.prepareWorkCompletionTransitions(from: [old], to: [afterAnswer], visibleIDs: []) + #expect(fresh.isExpanded(.work(id), default: true)) + } + + @Test func durationFloorsSecondsAndDoesNotDependOnTheRenderingClock() { + let start = Date(timeIntervalSince1970: 10_000) + let locale = Locale(identifier: "en") + for (elapsed, expected) in [(59.99, "59s"), (60.0, "1m"), (61.9, "1m 1s"), (3_600.0, "1h"), (-1.0, "0s")] { + #expect( + AgentWorkPresentation.duration(from: start, to: start.addingTimeInterval(elapsed), locale: locale) + == expected) + } + #expect(!AgentWorkPresentation.isExpandedByDefault(for: .completed)) + for state: AgentRunState in [.running, .waitingForUser, .waitingForApproval, .failed, .cancelled, .interrupted] + { + #expect(AgentWorkPresentation.isExpandedByDefault(for: state)) + } + } + + @Test func toolStateAndMixedBatchPreserveFailureSemantics() { + let states: [(AgentToolCard.State, AgentToolPresentation.BatchTint)] = [ + (.proposed, .secondary), (.running, .secondary), (.reviewing, .warning), + (.waitingForUser, .warning), (.waitingForApproval, .warning), (.completed, .success), + (.failed, .failure), (.denied, .failure), (.interrupted, .secondary), + ] + for (state, tint) in states { #expect(AgentToolPresentation.tint(for: state) == tint) } + let completed = card(state: .completed) + let failed = card(state: .failed) + #expect(AgentToolPresentation.batchTint(for: [completed, failed]) == .warning) + let title = AgentToolPresentation.batchSummary(for: [completed, failed], locale: Locale(identifier: "en")) + #expect(title.localizedCaseInsensitiveContains("failed")) + #expect(AgentToolPresentation.batchTint(for: [completed, card(state: .waitingForApproval)]) == .warning) + } + + @Test func aDirectDenialBeforeTheChannelDrainsKeepsOneDeniedCard() async throws { + let value = card(state: .proposed) + let conversation = ConversationModel() + let request = AgentApprovalRequest( + invocation: value.invocation, descriptor: value.descriptor, userIntent: "Inspect") + let answer = Task { await conversation.requestApproval(request) } + defer { conversation.respondToApproval(allow: false) } + for _ in 0..<100 where conversation.pendingApprovals.isEmpty { await Task.yield() } + _ = try #require(conversation.pendingApprovals.first) + conversation.respondToApproval(allow: false) + _ = await answer.value + conversation.handle(.toolProposed(value.invocation, value.descriptor)) + conversation.handle(.approvalRequested(request)) + conversation.handle( + .toolFinished( + value.invocation, + .init( + callID: value.invocation.call.id, content: "Denied", isError: true + ))) + #expect(conversation.toolCards.count == 1) + #expect(conversation.toolCards.first?.state == .denied) + #expect(conversation.transcript.count == 1) + } + + @Test func resultProjectionUsesTheInjectedPresenterAndRetainsFullCopyOutput() throws { + var value = card(state: .completed) + value.result = AgentToolResult(callID: value.invocation.call.id, content: "{\"answer\":42}") + let presenter = AgentToolDetailPresenter(id: "test.answer") { input in + [.field(.init(label: "Answer", value: input.result.objectValue?["answer"]?.encodedString ?? "missing"))] + } + let detail = try #require(AgentToolPresentation.detail(for: value, presenter: presenter)) + #expect(detail.items == [.field(.init(label: "Answer", value: "42"))]) + let full = (0..<250).map { "line \($0): " + String(repeating: "日", count: 80) }.joined(separator: "\n") + let preview = AgentToolPresentation.preview(full) + #expect(preview.isShortened) + #expect(preview.full == full) + #expect(preview.displayed.hasPrefix("line 0:")) + #expect(preview.displayed.contains("line 249:")) + #expect(preview.displayed.count <= AgentToolPresentation.displayCharacterLimit) + #expect(preview.displayed.split(separator: "\n", omittingEmptySubsequences: false).count <= 120) + } + + @Test func reasoningTitlesKeepEmphasisAndDoNotRenderRemoteImagesOrUnsafeLinks() { + let title = TermindMarkdownParser.inlineTitle( + "## **Inspect**\n\n`nginx` [logs](https://example.com) ![alt](https://example.com/p.png)") + #expect(String(title.characters) == "Inspect nginx logs alt") + #expect(title.runs.contains { $0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true }) + #expect(title.runs.contains { $0.inlinePresentationIntent?.contains(.code) == true }) + let unsafe = TermindMarkdownParser.inlineTitle("[command](file:///tmp/local)") + #expect(String(unsafe.characters) == "command") + #expect(unsafe.runs.allSatisfy { $0.link == nil }) + } + + private func work(id: UUID, state: AgentRunState, afterAnswer: Bool = false) -> AssistantTranscriptEntry { + .init( + id: id, + content: .work( + .init( + run: .init(id: id, state: state, startedAt: .distantPast), items: [], orderedAt: .distantPast, + appearsAfterAnswer: afterAnswer + ))) + } + + private func card(state: AgentToolCard.State) -> AgentToolCard { + .init( + invocation: .init( + runID: UUID(), call: .init(id: UUID().uuidString, name: "inspect", arguments: .object([:]))), + descriptor: .init( + name: "inspect", summary: "Inspect", inputSchema: .object([:]), target: .host, + safety: .locallyReadOnly, concurrency: .parallel), + state: state + ) + } + + @Test + func aReaderToggleSurvivesDefaultStateTransitions() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + + #expect(state.isExpanded(.work(id), default: true)) + state.toggleDisclosure(.work(id), default: true) + #expect(!state.isExpanded(.work(id), default: true)) + #expect(state.readerDidToggle(.work(id))) + + state.followDefault(true, for: .work(id)) + #expect(!state.isExpanded(.work(id), default: true)) + } + + @Test + func untouchedDisclosureFollowsItsRuntimeDefault() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + + state.followDefault(true, for: .work(id)) + #expect(state.isExpanded(.work(id))) + #expect(!state.readerDidToggle(.work(id))) + } + + @Test + func deferredAutoCollapseWaitsForTheCoordinatorAndRespectsReaderOverride() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + + state.deferAutoCollapse(id) + #expect(state.isExpanded(.work(id))) + #expect(state.hasPendingAutoCollapse(id)) + #expect(state.completeAutoCollapse(id)) + #expect(!state.isExpanded(.work(id))) + + state.deferAutoCollapse(id) + state.toggleDisclosure(.work(id), default: true) + #expect(!state.hasPendingAutoCollapse(id)) + #expect(!state.completeAutoCollapse(id)) + } + + @Test + func reasoningDeadlineIsAbsoluteAndNewMarkdownResetsIt() { + let id = UUID() + let state = AssistantTranscriptPresentationState() + let start = Date(timeIntervalSince1970: 1_000) + + let first = state.reasoningTitleDeadline( + for: id, revision: "first", now: start + ) + #expect(first == start.addingTimeInterval(3)) + #expect( + state.reasoningTitleDeadline( + for: id, revision: "first", now: start.addingTimeInterval(2) + ) == first) + #expect( + state.reasoningTitleDeadline( + for: id, revision: "second", now: start.addingTimeInterval(2) + ) == start.addingTimeInterval(5)) + } + +} diff --git a/TFMTests/ChatMessageTimestampTests.swift b/FrameworkTests/ChatMessageTimestampTests.swift similarity index 95% rename from TFMTests/ChatMessageTimestampTests.swift rename to FrameworkTests/ChatMessageTimestampTests.swift index a2991ab3..33987f32 100644 --- a/TFMTests/ChatMessageTimestampTests.swift +++ b/FrameworkTests/ChatMessageTimestampTests.swift @@ -1,14 +1,8 @@ import Foundation import TermindApplication -import TermindDomain -import TermindInfrastructure -import TermindPersistence import TermindPresentation -import TermindRemoteInfrastructure import Testing -@testable import Termind - @Suite struct ChatMessageTimestampTests { @Test diff --git a/FrameworkTests/MetricsJourneyTests.swift b/FrameworkTests/MetricsJourneyTests.swift index 23c64981..610bba2d 100644 --- a/FrameworkTests/MetricsJourneyTests.swift +++ b/FrameworkTests/MetricsJourneyTests.swift @@ -5,6 +5,33 @@ import Testing @Suite @MainActor struct MetricsJourneyTests { + @Test func metricsWorkerReturnsToMainActorAfterBackgroundSamplerCallbacks() async throws { + let identity = Identity(name: "Deploy", username: "deploy") + var host = TermindDomain.Host(name: "Monitor", host: "example.invalid") + host.identityID = identity.id + let hosts = HostLibraryModel( + hosts: [host], identities: [identity], + knownHosts: [ + KnownHostEntry( + hostname: host.host, port: host.port, algorithm: .ed25519, + fingerprint: "SHA256:test") + ]) + try hosts.upsert(identity, passwordUpdate: .replace("test-only")) + let sampler = BackgroundResumingMetricsSampler() + let model = ServerMetricsModel( + hosts: hosts, preferences: PortMetricsPreferences(), + makeSampler: { sampler }) + defer { model.shutdown() } + + #expect(model.add(host)) + let server = try #require(model.servers.first) + try await wait { server.point != nil } + + #expect(server.collectionState == .live) + #expect(sampler.snapshot().connections == 1) + #expect(sampler.snapshot().samples >= 1) + } + @Test func offlineRestoredMetricsReconcileOnMainActor() async throws { let identity = Identity(name: "Deploy", username: "deploy") var host = TermindDomain.Host(name: "Monitor", host: "example.invalid") @@ -189,6 +216,42 @@ private actor FailingMetricsSampler: MetricsSampling { func disconnect() async {} } +private nonisolated final class BackgroundResumingMetricsSampler: MetricsSampling, @unchecked Sendable { + private let lock = NSLock() + private var connections = 0 + private var samples = 0 + + func connect(_ configuration: SSHConnectionConfiguration) async throws { + await resumeFromBackground() + lock.withLock { connections += 1 } + } + + func sample() async throws -> MetricsSample { + await resumeFromBackground() + lock.withLock { samples += 1 } + return MetricsSample(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100) + } + + func gpu() async throws -> NvidiaSnapshot? { + await resumeFromBackground() + return nil + } + + func disconnect() async {} + + func snapshot() -> (connections: Int, samples: Int) { + lock.withLock { (connections, samples) } + } + + private func resumeFromBackground() async { + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume() + } + } + } +} + private nonisolated final class PortMetricsClock: @unchecked Sendable { private let lock = NSLock() private var date = Date(timeIntervalSince1970: 1_000) diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 9526bb21..7540e745 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -44,6 +44,7 @@ } }, "[… %lld characters omitted …]" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -184,6 +185,7 @@ } }, "%@ on %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -577,6 +579,7 @@ } }, "%lld questions" : { + "extractionState" : "stale", "localizations" : { "en" : { "variations" : { @@ -642,6 +645,7 @@ } }, "%lld times" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -652,6 +656,7 @@ } }, "%lld tool calls" : { + "extractionState" : "stale", "localizations" : { "en" : { "variations" : { @@ -1022,6 +1027,7 @@ } }, "a command" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1054,6 +1060,7 @@ } }, "a plan" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1064,6 +1071,7 @@ } }, "a question" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1074,6 +1082,7 @@ } }, "a remote file" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1095,6 +1104,7 @@ } }, "a scratch entry" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1105,6 +1115,7 @@ } }, "a scratch file" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1115,6 +1126,7 @@ } }, "a secret" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1126,6 +1138,7 @@ }, "a skill" : { "comment" : "Name of a skill.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "zh-Hans" : { @@ -1149,6 +1162,7 @@ } }, "a URL" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -1977,6 +1991,7 @@ } }, "ask" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -2166,6 +2181,7 @@ } }, "Automatic" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -2552,6 +2568,7 @@ } }, "change permissions for" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3242,6 +3259,7 @@ } }, "compare" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3252,6 +3270,7 @@ } }, "Completed without output" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3482,6 +3501,7 @@ } }, "Connecting to %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -3775,6 +3795,7 @@ } }, "copy" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4028,6 +4049,7 @@ } }, "create" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4243,6 +4265,7 @@ } }, "delete" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4576,6 +4599,7 @@ } }, "Denied %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4753,6 +4777,7 @@ } }, "Did not run “%@”" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -4763,6 +4788,7 @@ } }, "Did not run “%@” on %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5285,6 +5311,7 @@ } }, "edit" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5974,6 +6001,7 @@ } }, "Failed %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -5995,6 +6023,7 @@ } }, "Failed to run “%@”" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -6005,6 +6034,7 @@ } }, "Failed to run “%@” on %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -6038,6 +6068,7 @@ } }, "fetch" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -6950,6 +6981,7 @@ } }, "host metrics" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -6980,6 +7012,7 @@ } }, "hosts" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -7490,6 +7523,7 @@ } }, "inspect" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -7535,6 +7569,7 @@ } }, "Interrupted %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8017,6 +8052,7 @@ } }, "list" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8573,6 +8609,7 @@ } }, "move" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -8582,6 +8619,16 @@ } } }, + "Move this conversation to Archive" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将此对话移至归档" + } + } + } + }, "Name" : { "localizations" : { "zh-Hans" : { @@ -10629,6 +10676,7 @@ } }, "Preparing %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -10639,6 +10687,7 @@ } }, "Preparing to run “%@”" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -10649,6 +10698,7 @@ } }, "Preparing to run “%@” on %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -10670,6 +10720,7 @@ } }, "present" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -10940,6 +10991,7 @@ } }, "pull" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -10983,6 +11035,7 @@ } }, "push" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11092,6 +11145,7 @@ } }, "Ran “%@”" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11102,6 +11156,7 @@ } }, "Ran “%@” on %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11112,6 +11167,7 @@ } }, "Ran %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11132,6 +11188,7 @@ } }, "read" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11632,6 +11689,7 @@ } }, "Repeated tool action failed: %lld %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11642,6 +11700,7 @@ } }, "Repeated tool action interrupted: %lld %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -11715,6 +11774,7 @@ } }, "request" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12072,6 +12132,7 @@ } }, "run" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12165,6 +12226,7 @@ } }, "Running “%@”" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12175,6 +12237,7 @@ } }, "Running “%@” on %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12185,6 +12248,7 @@ } }, "Running %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12311,6 +12375,7 @@ } }, "scratch files" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -12342,6 +12407,7 @@ } }, "search" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13695,6 +13761,7 @@ } }, "Standard output:\n%@\nStandard error:\n%@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -13970,6 +14037,7 @@ } }, "Stopped waiting for “%@”" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -13980,6 +14048,7 @@ } }, "Stopped waiting for “%@” on %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -14062,6 +14131,7 @@ } }, "suggest" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15121,6 +15191,7 @@ } }, "the remote command" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15205,6 +15276,7 @@ } }, "the scratch workspace" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15353,6 +15425,7 @@ } }, "the SSH command" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15429,6 +15502,7 @@ } }, "the task list" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15460,6 +15534,7 @@ } }, "the web" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15889,6 +15964,7 @@ } }, "Tool action completed: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15899,6 +15975,7 @@ } }, "Tool action denied: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15909,6 +15986,7 @@ } }, "Tool action failed: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15919,6 +15997,7 @@ } }, "Tool action interrupted: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15929,6 +16008,7 @@ } }, "Tool action preparing: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15939,6 +16019,7 @@ } }, "Tool action running: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15949,6 +16030,7 @@ } }, "Tool action target: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -15959,6 +16041,7 @@ } }, "Tool action waiting: %@ %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -16575,6 +16658,7 @@ } }, "update" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -16752,6 +16836,7 @@ } }, "use" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17036,6 +17121,7 @@ } }, "wait for" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17090,6 +17176,7 @@ } }, "Waiting for authentication" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17143,6 +17230,7 @@ } }, "Waiting to authenticate with %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17153,6 +17241,7 @@ } }, "Waiting to run “%@”" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17163,6 +17252,7 @@ } }, "Waiting to run “%@” on %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17173,6 +17263,7 @@ } }, "Waiting to run %@" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17414,6 +17505,7 @@ } }, "write" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -17534,4 +17626,4 @@ } }, "version" : "1.2" -} +} \ No newline at end of file diff --git a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift index 616b54f6..41ce47ee 100644 --- a/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift +++ b/Frameworks/TermindApplication/Agent/AssistantLibraryModel.swift @@ -272,6 +272,16 @@ public final class AssistantLibraryModel { return draftConversation } + /// Starts a deliberately fresh New Chat surface. Platform navigation can + /// use this for an explicit New action, while `selectNewConversation()` + /// continues to preserve a draft when merely navigating away and back. + @discardableResult + public func resetDraftConversation() -> ConversationModel { + selectedConversationID = nil + draftConversation = ConversationModel() + return draftConversation + } + /// Promotes the New Chat scratch model on its first send, then prepares a /// fresh scratch model for the next visit to New Chat. @discardableResult @@ -286,8 +296,7 @@ public final class AssistantLibraryModel { /// real send. Empty drafts remain ephemeral and never create blank rows. @discardableResult public func startConversation(_ conversation: ConversationModel) -> ConversationModel? { - let text = conversation.composer.draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return nil } + guard conversation.canSendDraft else { return nil } guard !conversations.contains(where: { $0.id == conversation.id }) else { return nil } conversation.sendDraft() diff --git a/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift b/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift index 14b4c1c3..dc36cc2a 100644 --- a/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift +++ b/Frameworks/TermindApplication/Agent/ChatSlashCommand.swift @@ -35,59 +35,89 @@ 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 /// Shown after the name when the command takes free text. Empty for a /// command that takes none. public var argumentHint: String = "" public var name: String { "/\(id)" } - public init(id: String, displayName: String, summary: String, icon: Icon, argumentHint: String = "") { + /// A stable slash-free argument for an option whose source identifier may + /// itself contain `/` (model IDs commonly do). The parser deliberately uses + /// the last slash in the draft, so raw path-like IDs cannot be written into + /// an accepted command. + public static func argumentID(scope: String, value: String) -> String { + Data("\(scope)\u{0}\(value)".utf8).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + public init( + id: String, displayName: String, summary: String, icon: Icon, + argumentHint: String = "", isShownOnMobile: Bool = true + ) { self.id = id self.displayName = displayName self.summary = summary self.icon = icon self.argumentHint = argumentHint + self.isShownOnMobile = isShownOnMobile } } -/// What a draft beginning with `/` currently means. +/// What the trailing slash fragment in a draft currently means. nonisolated public enum ChatSlashCommandInput: Hashable, Sendable { - /// Still naming the command; carries what follows the slash. - case naming(String) + /// Still naming the command; carries the draft before the slash and what + /// follows it. + case naming(leadingText: String, partial: String) /// Named, with everything after the first run of spaces. - case argument(name: String, text: String) + case argument(leadingText: String, name: String, text: String) /// `nil` for a draft that is not a command. /// - /// Only the **first** line is considered, and it must start at the very - /// first character. A `/` inside prose, a path pasted into a longer message, - /// and a code block that happens to open with a slash are all ordinary - /// messages — the cost of getting this wrong is a message silently not sent. + /// Only the text after the **last** slash is considered. This lets someone + /// choose a mode or model after writing part of a prompt without turning an + /// earlier path into a command. A command fragment cannot span a newline. public static func parse(_ draft: String) -> ChatSlashCommandInput? { - guard draft.hasPrefix("/") else { return nil } - let firstLine = draft.prefix { !$0.isNewline } - // A draft with a second line has already committed to being a message: - // the palette closes on the newline, so anything multi-line reaching - // here is prose that began with a slash. - guard firstLine.count == draft.count else { return nil } - - let body = firstLine.dropFirst() + guard let slash = draft.lastIndex(of: "/") else { return nil } + let leadingText = String(draft[.. String { + leadingText + replacement + } + /// The command name typed so far, whether or not it is complete. public var name: String { switch self { - case .naming(let partial): partial - case .argument(let name, _): name + case .naming(_, let partial): partial + case .argument(_, let name, _): name } } @@ -98,7 +128,7 @@ nonisolated public enum ChatSlashCommandInput: Hashable, Sendable { /// rest of this file exists: what Return does with a draft should be /// testable without a window. `nil` when nothing was named at all. public var selection: (name: String, remainder: String)? { - guard case .argument(_, let text) = self else { return nil } + guard case .argument(_, _, let text) = self else { return nil } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) let name = String(trimmed.prefix { !$0.isWhitespace }) guard !name.isEmpty else { return nil } @@ -158,12 +188,12 @@ nonisolated public struct ChatSlashCommandCompletion: Hashable, Sendable { return ChatSlashCommandCompletion(rows: []) } switch input { - case .naming(let partial): + case .naming(_, let partial): let matched = commands.filter { partial.isEmpty || $0.id.hasPrefix(partial.lowercased()) } return ChatSlashCommandCompletion(rows: matched.map(Row.command)) - case .argument(let name, let text): + case .argument(_, let name, let text): guard let command = commands.first(where: { $0.id == name.lowercased() }) else { return ChatSlashCommandCompletion(rows: []) } @@ -181,13 +211,15 @@ nonisolated public struct ChatSlashCommandCompletion: Hashable, Sendable { /// /// Completing a name that takes an argument leaves a trailing space, so the /// palette advances to that command's options in the same keystroke. - public static func accepted(_ row: Row, takesArgument: Bool) -> String { - switch row { - case .command(let command): - takesArgument ? "\(command.name) " : command.name - case .option(let command, let option): - "\(command.name) \(option.id)" - } + public static func accepted(_ row: Row, takesArgument: Bool, in draft: String) -> String { + let replacement = + switch row { + case .command(let command): + takesArgument ? "\(command.name) " : command.name + case .option(let command, let option): + "\(command.name) \(option.id)" + } + return ChatSlashCommandInput.parse(draft)?.replacingCommand(with: replacement) ?? replacement } public init(rows: [Row]) { diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 72215003..3651401f 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -1924,6 +1924,9 @@ public final class ConversationModel: Identifiable { /// moves the card's state, and enqueueing happens here — on the path that /// holds the continuation and can therefore actually answer. public func requestApproval(_ request: AgentApprovalRequest) async -> AgentApprovalDecision { + // The approval callback can arrive before the channel drains toolProposed. + // Stage the same identity now so a direct user response has a card to update. + stageTool(request.invocation, descriptor: request.descriptor) // Inserted by when the call was *planned*, not by when it got here. A // parallel batch races to this queue, so appending put the cards in // whichever order the scheduler happened to resolve — the same two @@ -2459,7 +2462,11 @@ public final class ConversationModel: Identifiable { // The card only. The queue belongs to `requestApproval`, which is the // path that also holds the continuation — see its comment. case .approvalRequested(let request): - setToolState(request.invocation.id, .waitingForApproval) + // The direct user reply can beat this queued channel event to the UI. + // A late notification must not erase a denial already shown on the card. + updateToolCard(request.invocation.id) { card in + if !card.state.isTerminal { card.state = .waitingForApproval } + } case .reviewStarted(let request): reviewing.insert(request.invocation.id) setToolState(request.invocation.id, .reviewing) @@ -2493,6 +2500,17 @@ public final class ConversationModel: Identifiable { _ invocation: AgentToolInvocation, descriptor: AgentToolDescriptor ) { + // A direct interaction may have staged this call before its queued event. + // Refresh metadata without losing its state or creating a duplicate row. + if updateToolCard( + invocation.id, + { card in + card.invocation = invocation + card.descriptor = descriptor + }) + { + return + } let card = AgentToolCard(invocation: invocation, descriptor: descriptor) guard let sourceMessageID = invocation.sourceMessageID else { toolCards.append(card) diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 2a752d8a..5f952f0f 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -69,7 +69,19 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private var stamps: [UUID: Data] = [:] private struct PendingHostKeyAuthorization { var id: UUID - var continuation: CheckedContinuation + var continuation: AsyncStream.Continuation + } + private nonisolated struct RunAttempt: Sendable { + var host: Host + var preparation: SSHConnectionPreparation + var sampler: any MetricsSampling + } + private nonisolated struct HostKeyAuthorization: Sendable { + var id: UUID + var decisions: AsyncStream + } + private nonisolated enum RunCycleDisposition: Sendable { + case stop, attention, retry } private var pendingHostKeyAuthorizations: [UUID: PendingHostKeyAuthorization] = [:] @@ -476,7 +488,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading { server.collectionState = .connecting server.gpuState = .probing stamps[server.id] = connectionStamp(host) - workers[server.id] = Task { [weak self] in await self?.run(server, token: token) } + let serverID = server.id + let clock = clock + workers[server.id] = Task.detached { [weak self] in + await self?.run(serverID, token: token, clock: clock) + } } private func beginConnectionJourney(for server: ServerMetric, host: Host) { @@ -486,156 +502,282 @@ public final class ServerMetricsModel: MetricsSnapshotReading { ) } - private func current(_ server: ServerMetric, _ token: UUID) -> Bool { - !Task.isCancelled && generations[server.id] == token && !stopped && !sleeping + private func current(_ serverID: UUID, _ token: UUID) -> Bool { + !Task.isCancelled && generations[serverID] == token && !stopped && !sleeping } - private func run(_ server: ServerMetric, token: UUID) async { + private nonisolated func run(_ serverID: UUID, token: UUID, clock: MetricsClock) async { var delay = 2.0 - while current(server, token) { - let sampler = makeSampler() - samplers[server.id] = sampler + while await isCurrent(serverID, token: token) { + let attempt: RunAttempt do { - guard let host = hosts.host(server.id) else { return } - if server.journey.phase != .connecting { - beginConnectionJourney(for: server, host: host) - } - server.status = String(localized: "Connecting…") - server.collectionState = .connecting - server.isConnecting = true - let configuration = try await preparation(for: host, server: server).resolve( + guard let next = try await beginRunAttempt(serverID, token: token) else { return } + attempt = next + } catch { + guard await handleRunFailure(error, serverID: serverID, token: token) else { return } + guard await finishRunCycle(serverID, token: token) == .retry else { return } + do { try await clock.sleep(delay) } catch { return } + delay = min(60, delay * 2) + continue + } + + let sampler = attempt.sampler + do { + let configuration = try await attempt.preparation.resolve( trustPolicy: .allowChallenge ) - guard current(server, token) else { return } + guard await isCurrent(serverID, token: token) else { + await sampler.disconnect() + return + } try await sampler.connect( SSHConnectionRequest( configuration: configuration, - authorizeHostKey: { [weak self, weak server] challenge in - guard let self, let server else { throw CancellationError() } - return await self.authorizeHostKey( - server, challenge: challenge, token: token + authorizeHostKey: { [weak self] challenge in + guard let self else { throw CancellationError() } + return await self.authorizeHostKeyFromWorker( + serverID, challenge: challenge, token: token ) } ) ) - guard current(server, token) else { + guard await publishConnected(serverID, token: token, hostID: attempt.host.id) else { await sampler.disconnect() return } - server.isConnecting = false - server.journey.receive(.connected(host.id)) - server.status = String(localized: "Connected — collecting metrics…") - server.collectionState = .collecting var previous: MetricsSample? var previousProcesses: MetricsProcessSample? var nextFilesystemRefresh = Date.distantPast var probeGPU = true - while current(server, token) { + sampleLoop: while await isCurrent(serverID, token: token) { let began = clock.now() let sample = try await sampler.sample() - guard current(server, token) else { break } - var point = MetricsCalculation.point( - previous: previous, current: sample, selection: server.selection, date: clock.now() - ) + guard + var point = await publishBaseSample( + sample, previous: previous, serverID: serverID, token: token) + else { break } previous = sample - server.disks = sample.disks.map { $0.keys.sorted() } ?? server.disks - server.interfaces = sample.interfaces.map { $0.keys.sorted() } ?? server.interfaces - server.status = String(localized: "Live") - server.collectionState = .live - if server.journey.phase == .connecting { server.journey.succeed() } - // Publish base metrics before the optional, bounded GPU command. - server.point = point delay = 2 if probeGPU { do { let gpu = try await sampler.gpu() - guard current(server, token) else { break } - server.gpuSnapshot = gpu - server.gpuDate = clock.now() + guard + let updated = await publishGPU( + gpu, point: point, serverID: serverID, token: token) + else { break sampleLoop } + point = updated let devices = gpu?.devices ?? [] probeGPU = !devices.isEmpty - server.gpuState = devices.isEmpty ? .unavailable : .available - let ids = Set(devices.map(\.id)) - for index in server.history.indices { - server.history[index].gpus.removeAll { !ids.contains($0.id) } - } - point.gpus = devices } catch { - guard current(server, token) else { break } - server.gpuState = .failed + guard await markGPUFailed(serverID, token: token) else { break sampleLoop } // Unknown GPU values create a gap, not a false zero or a repeated stale sample. } } - if detailObservers.values.contains(server.id) { + if await observesDetails(serverID, token: token) { if clock.now() >= nextFilesystemRefresh { do { if let filesystems = try await sampler.filesystems() { - guard current(server, token) else { break } - server.filesystems = filesystems - server.filesystemsDate = clock.now() - server.filesystemsFailed = false + guard + await publishFilesystems( + filesystems, serverID: serverID, token: token) + else { break sampleLoop } } } catch { - guard current(server, token) else { break } - server.filesystemsFailed = true + guard await markFilesystemsFailed(serverID, token: token) else { + break sampleLoop + } } nextFilesystemRefresh = clock.now().addingTimeInterval(30) } do { if let processes = try await sampler.processes() { - guard current(server, token) else { break } - server.processes = MetricsProcess.rows(previous: previousProcesses, current: processes) + guard + await publishProcesses( + processes, previous: previousProcesses, + serverID: serverID, token: token) + else { break sampleLoop } previousProcesses = processes - server.processesDate = clock.now() - server.processesFailed = false } } catch { - guard current(server, token) else { break } - server.processesFailed = true + guard await markProcessesFailed(serverID, token: token) else { break sampleLoop } previousProcesses = nil } } else { previousProcesses = nil } - server.point = point - server.history.append(point) - server.history.removeAll { $0.date < clock.now().addingTimeInterval(-300) } + guard await finishSample(point, serverID: serverID, token: token) else { break } try await clock.sleep(min(2, max(0.05, 2 - clock.now().timeIntervalSince(began)))) } } catch { - guard current(server, token) else { + guard await handleRunFailure(error, serverID: serverID, token: token) else { await sampler.disconnect() return } - server.isConnecting = false - server.status = error.localizedDescription - server.collectionState = .failed - if let ssh = error as? TermindSSHError { - let targetID = ssh.routeHostID ?? server.id - if ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication { - requestAuthentication(server, hostID: targetID, error: ssh) - } - } - if case MetricsReadError.unsupported = error { server.needsAttention = true } - if !server.requiresAuthentication { server.journey.fail(error.localizedDescription) } } await sampler.disconnect() - guard current(server, token) else { return } - if server.needsAttention { - workers[server.id] = nil - samplers[server.id] = nil - return - } + guard await finishRunCycle(serverID, token: token) == .retry else { return } do { try await clock.sleep(delay) } catch { return } delay = min(60, delay * 2) } } - private func authorizeHostKey( - _ server: ServerMetric, challenge: SSHHostKeyChallenge, token: UUID + private func beginRunAttempt(_ serverID: UUID, token: UUID) throws -> RunAttempt? { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil } + guard let host = hosts.host(serverID) else { return nil } + if server.journey.phase != .connecting { + beginConnectionJourney(for: server, host: host) + } + server.status = String(localized: "Connecting…") + server.collectionState = .connecting + server.isConnecting = true + let preparation = try preparation(for: host, server: server) + let sampler = makeSampler() + samplers[serverID] = sampler + return RunAttempt(host: host, preparation: preparation, sampler: sampler) + } + + private func isCurrent(_ serverID: UUID, token: UUID) -> Bool { + current(serverID, token) + } + + private func publishConnected(_ serverID: UUID, token: UUID, hostID: UUID) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.isConnecting = false + server.journey.receive(.connected(hostID)) + server.status = String(localized: "Connected — collecting metrics…") + server.collectionState = .collecting + return true + } + + private func publishBaseSample( + _ sample: MetricsSample, previous: MetricsSample?, serverID: UUID, token: UUID + ) -> MetricsPoint? { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil } + let point = MetricsCalculation.point( + previous: previous, current: sample, selection: server.selection, date: clock.now() + ) + server.disks = sample.disks.map { $0.keys.sorted() } ?? server.disks + server.interfaces = sample.interfaces.map { $0.keys.sorted() } ?? server.interfaces + server.status = String(localized: "Live") + server.collectionState = .live + if server.journey.phase == .connecting { server.journey.succeed() } + // Publish base metrics before the optional, bounded GPU command. + server.point = point + return point + } + + private func publishGPU( + _ gpu: NvidiaSnapshot?, point: MetricsPoint, serverID: UUID, token: UUID + ) -> MetricsPoint? { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil } + server.gpuSnapshot = gpu + server.gpuDate = clock.now() + let devices = gpu?.devices ?? [] + server.gpuState = devices.isEmpty ? .unavailable : .available + let ids = Set(devices.map(\.id)) + for index in server.history.indices { + server.history[index].gpus.removeAll { !ids.contains($0.id) } + } + var point = point + point.gpus = devices + return point + } + + private func markGPUFailed(_ serverID: UUID, token: UUID) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.gpuState = .failed + return true + } + + private func observesDetails(_ serverID: UUID, token: UUID) -> Bool { + current(serverID, token) && detailObservers.values.contains(serverID) + } + + private func publishFilesystems(_ values: [MetricsFilesystem], serverID: UUID, token: UUID) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.filesystems = values + server.filesystemsDate = clock.now() + server.filesystemsFailed = false + return true + } + + private func markFilesystemsFailed(_ serverID: UUID, token: UUID) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.filesystemsFailed = true + return true + } + + private func publishProcesses( + _ values: MetricsProcessSample, previous: MetricsProcessSample?, serverID: UUID, token: UUID + ) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.processes = MetricsProcess.rows(previous: previous, current: values) + server.processesDate = clock.now() + server.processesFailed = false + return true + } + + private func markProcessesFailed(_ serverID: UUID, token: UUID) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.processesFailed = true + return true + } + + private func finishSample(_ point: MetricsPoint, serverID: UUID, token: UUID) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.point = point + server.history.append(point) + server.history.removeAll { $0.date < clock.now().addingTimeInterval(-300) } + return true + } + + private func handleRunFailure(_ error: any Error, serverID: UUID, token: UUID) -> Bool { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return false } + server.isConnecting = false + server.status = error.localizedDescription + server.collectionState = .failed + if let ssh = error as? TermindSSHError { + let targetID = ssh.routeHostID ?? serverID + if ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication { + requestAuthentication(server, hostID: targetID, error: ssh) + } + } + if case MetricsReadError.unsupported = error { server.needsAttention = true } + if !server.requiresAuthentication { server.journey.fail(error.localizedDescription) } + return true + } + + private func finishRunCycle(_ serverID: UUID, token: UUID) -> RunCycleDisposition { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return .stop } + if server.needsAttention { + workers[serverID] = nil + samplers[serverID] = nil + return .attention + } + return .retry + } + + private nonisolated func authorizeHostKeyFromWorker( + _ serverID: UUID, challenge: SSHHostKeyChallenge, token: UUID ) async -> SSHHostKeyDecision { - guard current(server, token) else { return .reject } - finishHostKeyAuthorization(for: server.id, decision: .reject) + guard let authorization = await beginHostKeyAuthorization(serverID, challenge: challenge, token: token) else { + return .reject + } + return await withTaskCancellationHandler { + for await decision in authorization.decisions { return decision } + return .reject + } onCancel: { + Task.detached { [weak self] in + await self?.cancelHostKeyAuthorization(for: serverID, id: authorization.id) + } + } + } + + private func beginHostKeyAuthorization( + _ serverID: UUID, challenge: SSHHostKeyChallenge, token: UUID + ) -> HostKeyAuthorization? { + guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil } + finishHostKeyAuthorization(for: serverID, decision: .reject) if !challenge.expectedHostKeys.isEmpty { hosts.markKnownHostMismatched( hostname: challenge.observation.hostname, @@ -657,19 +799,12 @@ public final class ServerMetricsModel: MetricsSnapshotReading { fingerprint: challenge.observation.fingerprint ) let authorizationID = UUID() - return await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - pendingHostKeyAuthorizations[server.id] = PendingHostKeyAuthorization( - id: authorizationID, continuation: continuation - ) - } - } onCancel: { - Task { @MainActor [weak self] in - self?.cancelHostKeyAuthorization( - for: server.id, id: authorizationID - ) - } + let decisions = AsyncStream { continuation in + pendingHostKeyAuthorizations[serverID] = PendingHostKeyAuthorization( + id: authorizationID, continuation: continuation + ) } + return HostKeyAuthorization(id: authorizationID, decisions: decisions) } private func cancelHostKeyAuthorization(for serverID: UUID, id: UUID) { @@ -680,8 +815,9 @@ public final class ServerMetricsModel: MetricsSnapshotReading { private func finishHostKeyAuthorization( for serverID: UUID, decision: SSHHostKeyDecision ) { - pendingHostKeyAuthorizations.removeValue(forKey: serverID)? - .continuation.resume(returning: decision) + guard let pending = pendingHostKeyAuthorizations.removeValue(forKey: serverID) else { return } + pending.continuation.yield(decision) + pending.continuation.finish() } } diff --git a/TFM/Features/AI/AgentToolPresentation.swift b/Frameworks/TermindPresentation/AgentToolPresentation.swift similarity index 97% rename from TFM/Features/AI/AgentToolPresentation.swift rename to Frameworks/TermindPresentation/AgentToolPresentation.swift index 171d00a9..40dc2d3f 100644 --- a/TFM/Features/AI/AgentToolPresentation.swift +++ b/Frameworks/TermindPresentation/AgentToolPresentation.swift @@ -1,33 +1,32 @@ import AgentKit import Foundation import TermindApplication -import TermindRemoteInfrastructure /// Turns tool protocol data into the small amount of language the transcript needs. /// Execution, approval and replay continue to use the original call and result unchanged. -nonisolated enum AgentToolPresentation { - enum BatchTint: Equatable { +nonisolated public enum AgentToolPresentation { + public enum BatchTint: Equatable, Sendable { case secondary, warning, success, failure } - static let displayCharacterLimit = 8_192 - static let displayLineLimit = 120 + public static let displayCharacterLimit = 8_192 + public static let displayLineLimit = 120 - nonisolated struct OutputPreview: Equatable, Sendable { - let displayed: String - let full: String - let isShortened: Bool - let omittedCharacters: Int + nonisolated public struct OutputPreview: Equatable, Sendable { + public let displayed: String + public let full: String + public let isShortened: Bool + public let omittedCharacters: Int } - static func symbol(for card: AgentToolCard) -> String { + public static func symbol(for card: AgentToolCard) -> String { return card.descriptor.presentation?.symbol ?? "wrench.and.screwdriver" } /// While work is live, use the icon of the tool(s) still in flight. After /// completion, a homogeneous batch keeps its tool icon; mixed batches use /// the generic tools symbol. - static func batchSymbol(for cards: [AgentToolCard]) -> String { + public static func batchSymbol(for cards: [AgentToolCard]) -> String { let unfinished = cards.filter { !$0.state.isTerminal } if let live = unfinished.first { return symbol(for: live) } guard let first = cards.first else { return "wrench.and.screwdriver" } @@ -36,7 +35,7 @@ nonisolated enum AgentToolPresentation { ? symbol(for: first) : "wrench.and.screwdriver" } - static func batchTint(for cards: [AgentToolCard]) -> BatchTint { + public static func batchTint(for cards: [AgentToolCard]) -> BatchTint { if let live = cards.first(where: { !$0.state.isTerminal }) { return tint(for: live.state) } @@ -52,11 +51,11 @@ nonisolated enum AgentToolPresentation { /// Converts provider-authored Markdown into the one-line prose a tool /// batch header can accommodate. Structure and emphasis remain available /// in the live reasoning row; here only the visible words are reused. - static func reasoningTitle(from markdown: String) -> String { + public static func reasoningTitle(from markdown: String) -> String { TermindMarkdownParser.plainText(markdown) } - static func summary( + public static func summary( for card: AgentToolCard, hostName: String? = nil, locale: Locale = .current @@ -123,7 +122,7 @@ nonisolated enum AgentToolPresentation { /// metadata as its individual rows but with repeated activities counted. /// Calls in different terminal states stay separate, so one failure can /// never be rewritten as a successful action by the batch title. - static func batchSummary( + public static func batchSummary( for cards: [AgentToolCard], locale: Locale = .current ) -> String { struct Key: Hashable { @@ -149,7 +148,7 @@ nonisolated enum AgentToolPresentation { .joined(separator: ", ") } - static func output(from result: AgentToolResult, presentation: AgentToolDescriptor.Presentation?) -> String { + public static func output(from result: AgentToolResult, presentation: AgentToolDescriptor.Presentation?) -> String { let raw = result.untrustedPayload ?? result.content return output(raw, policy: presentation?.output ?? .automatic) } @@ -158,7 +157,7 @@ nonisolated enum AgentToolPresentation { /// in the tool result: people need the former and the model must receive /// the latter. The transcript, however, should not print identical text /// twice. Distinct execution failures remain visible below the review. - static func detailOutput( + public static func detailOutput( from result: AgentToolResult, presentation: AgentToolDescriptor.Presentation?, review: SecurityReviewDecision? @@ -173,8 +172,8 @@ nonisolated enum AgentToolPresentation { /// Projects protocol-shaped tool results into transcript-shaped content. /// The model and persistence keep the original JSON; only this projection /// is allowed to decide what a person sees. - static func detail( - for card: AgentToolCard, locale: Locale = .current + public static func detail( + for card: AgentToolCard, presenter: AgentToolDetailPresenter? = nil, locale: Locale = .current ) -> AgentToolDetail? { guard let result = card.result else { return nil } @@ -206,14 +205,6 @@ nonisolated enum AgentToolPresentation { ]) } - let presenter: AgentToolDetailPresenter? - if let id = card.descriptor.presentation?.presenterID { - presenter = TermindAgentTools.presenter(id: id) - } else { - presenter = TermindAgentTools.legacyPresenter( - forToolNamed: card.descriptor.name - ) - } guard let presenter else { return AgentToolDetail( items: AgentToolDetailFormatting.genericItems(value, locale: locale) @@ -230,7 +221,7 @@ nonisolated enum AgentToolPresentation { /// A bounded rendering projection. The full transformed output stays beside /// it for Copy, while the Text view only receives the small preview. - static func preview( + public static func preview( _ full: String, characterLimit: Int = displayCharacterLimit, lineLimit: Int = displayLineLimit @@ -1058,7 +1049,7 @@ nonisolated enum AgentToolPresentation { return first.lowercased() + String(text.dropFirst()) } - private static func tint(for state: AgentToolCard.State) -> BatchTint { + public static func tint(for state: AgentToolCard.State) -> BatchTint { switch state { case .denied, .failed: .failure case .reviewing, .waitingForApproval, .waitingForUser: .warning diff --git a/Frameworks/TermindPresentation/AssistantContextGauge.swift b/Frameworks/TermindPresentation/AssistantContextGauge.swift index 45c3e12f..c0764b8a 100644 --- a/Frameworks/TermindPresentation/AssistantContextGauge.swift +++ b/Frameworks/TermindPresentation/AssistantContextGauge.swift @@ -94,18 +94,25 @@ public struct ContextBreakdownView: View { /// providers. The parts still mean something; the share does not. public let contextWindow: Int? public let cachedInputTokens: Int + public let showsTitle: Bool - public init(estimate: AgentContextEstimate, contextWindow: Int?, cachedInputTokens: Int) { + public init( + estimate: AgentContextEstimate, contextWindow: Int?, cachedInputTokens: Int, + showsTitle: Bool = true + ) { self.estimate = estimate self.contextWindow = contextWindow self.cachedInputTokens = cachedInputTokens + self.showsTitle = showsTitle } public var body: some View { VStack(alignment: .leading, spacing: 12) { - Text("Context") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + if showsTitle { + Text("Context") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + } ForEach(Array(rows.enumerated()), id: \.offset) { _, row in HStack(alignment: .firstTextBaseline, spacing: 16) { diff --git a/Frameworks/TermindPresentation/AssistantMessageBody.swift b/Frameworks/TermindPresentation/AssistantMessageBody.swift index 85b0421b..29bb8390 100644 --- a/Frameworks/TermindPresentation/AssistantMessageBody.swift +++ b/Frameworks/TermindPresentation/AssistantMessageBody.swift @@ -1,9 +1,25 @@ +import MarkdownParser import MarkdownView import SwiftUI /// Both hosts use the same Markdown engine; their composers remain platform-owned. public struct AssistantMessageBody: View { private let text: String - public init(_ text: String) { self.text = text } - public var body: some View { MarkdownView(text).frame(maxWidth: .infinity, alignment: .leading) } + private let theme: MarkdownTheme + + public init(_ text: String, theme: MarkdownTheme = .default) { + self.text = text + self.theme = theme + } + + public var body: some View { + let parsed = MarkdownParser().parse(MarkdownSoftBreaks.hardened(text)) + let content = MarkdownContent( + blocks: TermindMarkdownParser.sanitize(parsed.document), + rendered: parsed.renderedContent(theme: theme), + highlightMaps: parsed.highlightMaps(theme: theme), + locale: .autoupdatingCurrent + ) + MarkdownView(content, theme: theme).frame(maxWidth: .infinity, alignment: .leading) + } } diff --git a/Frameworks/TermindPresentation/AssistantToolDetails.swift b/Frameworks/TermindPresentation/AssistantToolDetails.swift index ed179589..caf20b24 100644 --- a/Frameworks/TermindPresentation/AssistantToolDetails.swift +++ b/Frameworks/TermindPresentation/AssistantToolDetails.swift @@ -3,7 +3,13 @@ import SwiftUI public struct AssistantToolDetails: View { private let items: [AgentToolDetail.Item] - public init(items: [AgentToolDetail.Item]) { self.items = items } + private let isError: Bool + private let copy: ((String) -> Void)? + public init(items: [AgentToolDetail.Item], isError: Bool = false, copy: ((String) -> Void)? = nil) { + self.items = items + self.isError = isError + self.copy = copy + } public var body: some View { VStack(alignment: .leading, spacing: 10) { ForEach(items.indices, id: \.self) { index in item(items[index]) } @@ -13,23 +19,40 @@ public struct AssistantToolDetails: View { @ViewBuilder private func item(_ value: AgentToolDetail.Item) -> some View { switch value { case .field(let field): - LabeledContent(field.label) { - Text(field.value).font(field.isMonospaced ? .caption.monospaced() : .caption) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(field.label).font(.caption.weight(.medium)).foregroundStyle(.secondary) + if let url = allowedURL(field.url) { + Link(field.value, destination: url).font(.caption) + } else { + Text(field.value).font(field.isMonospaced ? .caption.monospaced() : .caption) + .foregroundStyle(isError ? Color.red : .primary) + } } case .message(let text, let tint): Text(text).foregroundStyle(color(tint)) case .text(let block): - if let title = block.title { Text(title).font(.headline) } + if let title = block.title { Text(title).font(.caption.weight(.medium)).foregroundStyle(.secondary) } + let preview = AgentToolPresentation.preview(block.text) ScrollView(.horizontal) { - Text(block.text).font(block.style == .monospaced ? .callout.monospaced() : .callout) + Text(preview.displayed).font(block.style == .monospaced ? .caption.monospaced() : .caption) + .foregroundStyle(isError ? Color.red : .secondary) + } + if preview.isShortened { + HStack { + Text("Output shortened for display").font(.caption2).foregroundStyle(.secondary) + if let copy { + Button("Copy", systemImage: "doc.on.doc") { copy(preview.full) } + .font(.caption).frame(minHeight: 44) + } + } } case .list(let list): - if let title = list.title { Text(title).font(.headline) } + if let title = list.title { Text(title).font(.caption.weight(.medium)).foregroundStyle(.secondary) } if list.rows.isEmpty, let empty = list.emptyMessage { Text(empty).foregroundStyle(.secondary) } rows(list.rows) case .groupedList(let list): Text(list.summary).font(.caption) ForEach(list.sections.indices, id: \.self) { index in - Text(list.sections[index].title).font(.headline) + Text(list.sections[index].title).font(.caption.weight(.medium)).foregroundStyle(.secondary) rows(list.sections[index].rows) } } @@ -45,9 +68,7 @@ public struct AssistantToolDetails: View { if let subtitle = row.subtitle { Text(subtitle).font(.caption).foregroundStyle(.secondary) } if let detail = row.detail { Text(detail).font(.caption.monospaced()) } if !row.badges.isEmpty { Text(row.badges.joined(separator: " · ")).font(.caption) } - if let string = row.url, let url = URL(string: string), - ["http", "https"].contains(url.scheme?.lowercased() ?? "") - { + if let url = allowedURL(row.url) { Link("Open", destination: url) } } @@ -55,6 +76,11 @@ public struct AssistantToolDetails: View { } } + private func allowedURL(_ value: String?) -> URL? { + guard let value, TermindMarkdownParser.isAllowedLink(value) else { return nil } + return URL(string: value) + } + private func color(_ tint: AgentToolDetail.Tint) -> Color { switch tint { case .secondary: .secondary diff --git a/Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift b/Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift new file mode 100644 index 00000000..7ae7acf3 --- /dev/null +++ b/Frameworks/TermindPresentation/AssistantTranscriptPresentationState.swift @@ -0,0 +1,143 @@ +import Foundation +import Observation +import TermindApplication + +nonisolated public enum AssistantTranscriptDisclosureID: Hashable, Sendable { + case work(UUID) + case tool(UUID) + case toolBatch(UUID) + case reasoning(UUID) + case plan(UUID) + case compactionActivity(UUID) + + fileprivate var sourceID: UUID { + switch self { + case .work(let id), .tool(let id), .toolBatch(let id), .reasoning(let id), .plan(let id), + .compactionActivity(let id): + id + } + } +} + +@MainActor +@Observable +public final class AssistantTranscriptPresentationState { + public static let reasoningTitleDuration: TimeInterval = 3 + + private struct DisclosureState { + var isExpanded: Bool + var readerDidToggle: Bool + var revision: UInt64 + } + private struct ReasoningTitleState { + var revision: String + var expiresAt: Date + } + private var disclosureStates: [AssistantTranscriptDisclosureID: DisclosureState] = [:] + private var pendingAutoCollapseIDs: Set = [] + private var reasoningTitles: [UUID: ReasoningTitleState] = [:] + private var nextRevision: UInt64 = 1 + + public init() {} + + public func isExpanded(_ id: AssistantTranscriptDisclosureID, default value: Bool = false) -> Bool { + disclosureStates[id]?.isExpanded ?? value + } + public func readerDidToggle(_ id: AssistantTranscriptDisclosureID) -> Bool { + disclosureStates[id]?.readerDidToggle == true + } + public func toggleDisclosure(_ id: AssistantTranscriptDisclosureID, default value: Bool = false) { + pendingAutoCollapseIDs.remove(id) + disclosureStates[id] = .init( + isExpanded: !(disclosureStates[id]?.isExpanded ?? value), + readerDidToggle: true, + revision: takeRevision() + ) + } + public func followDefault(_ expanded: Bool, for id: AssistantTranscriptDisclosureID) { + guard disclosureStates[id]?.readerDidToggle != true else { return } + pendingAutoCollapseIDs.remove(id) + disclosureStates[id] = .init( + isExpanded: expanded, readerDidToggle: false, revision: takeRevision() + ) + } + public func deferAutoCollapse(_ workID: UUID) { + let id = AssistantTranscriptDisclosureID.work(workID) + guard disclosureStates[id]?.readerDidToggle != true else { return } + pendingAutoCollapseIDs.insert(id) + disclosureStates[id] = .init( + isExpanded: true, readerDidToggle: false, revision: takeRevision() + ) + } + public func hasPendingAutoCollapse(_ workID: UUID) -> Bool { + pendingAutoCollapseIDs.contains(.work(workID)) + } + @discardableResult + public func completeAutoCollapse(_ workID: UUID) -> Bool { + let id = AssistantTranscriptDisclosureID.work(workID) + guard pendingAutoCollapseIDs.remove(id) != nil, + disclosureStates[id]?.readerDidToggle != true + else { return false } + disclosureStates[id] = .init( + isExpanded: false, readerDidToggle: false, revision: takeRevision() + ) + return true + } + + /// Returns an absolute deadline so remounting or measuring a row cannot + /// restart its transient reasoning title. A changed Markdown revision does + /// deliberately receive a fresh three-second window. + public func reasoningTitleDeadline( + for id: UUID, + revision: String, + now: Date = .now + ) -> Date { + if let state = reasoningTitles[id], state.revision == revision { + return state.expiresAt + } + let deadline = now.addingTimeInterval(Self.reasoningTitleDuration) + reasoningTitles[id] = .init(revision: revision, expiresAt: deadline) + return deadline + } + /// Called before list updates, while the previous geometry is still available. + public func prepareWorkCompletionTransitions( + from old: [AssistantTranscriptEntry], to new: [AssistantTranscriptEntry], visibleIDs: Set + ) { + let previous = Dictionary(uniqueKeysWithValues: old.map { ($0.id, $0) }) + for entry in new { + guard case .work(let group) = entry.content, + group.run.state == .completed, !group.appearsAfterAnswer, + case .work(let oldGroup)? = previous[entry.id]?.content, + oldGroup.run.state != .completed, !readerDidToggle(.work(entry.id)) + else { continue } + if visibleIDs.contains(entry.id) { + deferAutoCollapse(entry.id) + } else { + followDefault(false, for: .work(entry.id)) + } + } + } + + @discardableResult + public func collapseWorkOutsideViewport(visibleIDs: Set) -> Set { + let candidates = pendingAutoCollapseIDs.filter { !visibleIDs.contains($0.sourceID) } + return Set(candidates.compactMap { completeAutoCollapse($0.sourceID) ? $0.sourceID : nil }) + } + + public func prune(validTimelineIDs: Set) { + for id in disclosureStates.keys where !validTimelineIDs.contains(id.sourceID) { disclosureStates[id] = nil } + pendingAutoCollapseIDs = Set(pendingAutoCollapseIDs.filter { validTimelineIDs.contains($0.sourceID) }) + for id in reasoningTitles.keys where !validTimelineIDs.contains(id) { reasoningTitles[id] = nil } + } + + public func layoutRevision(for ids: Set) -> UInt64 { + disclosureStates.reduce(0) { result, entry in + ids.contains(entry.key.sourceID) ? result &+ entry.value.revision : result + } + } + + private func takeRevision() -> UInt64 { + defer { nextRevision &+= 1 } + return nextRevision + } +} diff --git a/Frameworks/TermindPresentation/AssistantTranscriptPreview.swift b/Frameworks/TermindPresentation/AssistantTranscriptPreview.swift new file mode 100644 index 00000000..a753877a --- /dev/null +++ b/Frameworks/TermindPresentation/AssistantTranscriptPreview.swift @@ -0,0 +1,53 @@ +#if DEBUG + import AgentKit + import Foundation + import TermindApplication + + /// The same deterministic content exercises both native transcript renderers. + @MainActor + public enum AssistantTranscriptPreview { + public enum Phase: String, CaseIterable { + case thinking, tools, results + } + + public static func work(language: AppLanguage, phase: Phase, now: Date = .now) -> AgentWorkGroup { + let conversation = ConversationModel.demo(language: language) + var group = conversation.workGroups[0] + group.run.startedAt = now.addingTimeInterval(-65) + group.run.finishedAt = phase == .results ? now : nil + group.run.state = phase == .results ? .completed : .running + let reasoning = + language == .simplifiedChinese + ? "**检查上游服务。**\n先读取 `nginx` 日志,再核对监听端口;暂不更改配置。" + : "**Inspect the upstream service.**\nRead the `nginx` logs, then check listening ports before changing configuration." + if phase == .thinking { + let block = AgentReasoningBlock(text: reasoning, createdAt: now) + group.items = [.reasoning(block)] + group.activeReasoningBlockID = block.id + } else { + var cards = conversation.toolCards + cards[0].state = phase == .tools ? .running : .completed + cards[1].state = phase == .tools ? .completed : .failed + cards[0].result = AgentToolResult( + callID: cards[0].invocation.call.id, + content: "Active: active (running)\nMain PID: 421 (nginx)" + ) + cards[1].result = AgentToolResult( + callID: cards[1].invocation.call.id, + content: language == .simplifiedChinese + ? "连接上游服务失败:连接被拒绝。" : "connect() failed: connection refused", + isError: phase == .results + ) + group.items = [ + .tools( + .init( + cards: cards, runID: group.id, + reasoning: phase == .tools ? .init(sourceMessageID: UUID(), markdown: reasoning) : nil + )) + ] + group.activeReasoningBlockID = nil + } + return group + } + } +#endif diff --git a/Frameworks/TermindPresentation/AssistantTranscriptStyle.swift b/Frameworks/TermindPresentation/AssistantTranscriptStyle.swift new file mode 100644 index 00000000..be0b7e7e --- /dev/null +++ b/Frameworks/TermindPresentation/AssistantTranscriptStyle.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftUI + +/// Shared visual semantics; each native host resolves its own fonts and colors. +nonisolated public enum AssistantTranscriptStyle { + public static let blockSpacing: CGFloat = 10 + public static let bubbleRadius: CGFloat = 14 + public static let scanDuration: TimeInterval = 1.55 + public static let activityTextDuration: TimeInterval = 2.8 + public static let scanAlphas: [Double] = [0, 0.34, 0.82, 1, 0.82, 0.34, 0] + + public static func scanBandWidth(for width: CGFloat) -> CGFloat { + min(max(width * 0.32, 40), 96) + } +} + +extension AgentToolPresentation.BatchTint { + public var color: Color { + switch self { + case .secondary: .secondary + case .warning: .orange + case .success: .green + case .failure: .red + } + } +} diff --git a/TFM/Features/AI/MessageViews.swift b/Frameworks/TermindPresentation/ChatMessageTimestampFormatter.swift similarity index 88% rename from TFM/Features/AI/MessageViews.swift rename to Frameworks/TermindPresentation/ChatMessageTimestampFormatter.swift index 89d1a8c4..5b5a1ebc 100644 --- a/TFM/Features/AI/MessageViews.swift +++ b/Frameworks/TermindPresentation/ChatMessageTimestampFormatter.swift @@ -1,8 +1,8 @@ import Foundation import TermindApplication -nonisolated enum ChatMessageTimestampFormatter { - static func string( +nonisolated public enum ChatMessageTimestampFormatter { + public static func string( for date: Date, now: Date = .now, calendar: Calendar = .autoupdatingCurrent, diff --git a/Frameworks/TermindPresentation/DropTargetOverlay.swift b/Frameworks/TermindPresentation/DropTargetOverlay.swift new file mode 100644 index 00000000..cd405df7 --- /dev/null +++ b/Frameworks/TermindPresentation/DropTargetOverlay.swift @@ -0,0 +1,113 @@ +import SwiftUI + +public nonisolated struct DropTargetPresentation: Equatable, Sendable { + public nonisolated enum Tone: Equatable, Sendable { + case accent + case unavailable + } + + public let title: String + public let detail: String + public let systemImage: String + public var tone: Tone + + public init( + title: String, + detail: String, + systemImage: String, + tone: Tone = .accent + ) { + self.title = title + self.detail = detail + self.systemImage = systemImage + self.tone = tone + } +} + +/// One unmistakable drop affordance for every full-surface destination. +/// +/// The content under it remains visible enough to retain place, while the +/// moving dashed border and the compact glass card make the pending action +/// legible over a terminal, a file list, or a transcript. +public struct DropTargetOverlay: View { + public let presentation: DropTargetPresentation + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animatesSymbol = false + + private let cornerRadius: CGFloat = 10 + private var tint: Color { + presentation.tone == .unavailable ? .orange : .accentColor + } + + public init(presentation: DropTargetPresentation) { + self.presentation = presentation + } + + public var body: some View { + ZStack { + tint.opacity(0.09) + + RoundedRectangle(cornerRadius: cornerRadius) + .stroke( + tint, + style: StrokeStyle(lineWidth: 2, dash: [7, 5]) + ) + .padding(8) + + VStack(spacing: 6) { + Image(systemName: presentation.systemImage) + .font(.system(size: 24, weight: .semibold)) + .foregroundStyle(tint) + .symbolEffect( + .bounce.up, + options: reduceMotion ? .nonRepeating : .repeating.speed(0.55), + value: animatesSymbol + ) + Text(presentation.title) + .font(.headline) + Text(presentation.detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.middle) + } + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .modifier(DropTargetCardSurface(cornerRadius: cornerRadius)) + } + .allowsHitTesting(false) + .accessibilityElement(children: .combine) + .transition( + reduceMotion + ? .opacity + : .opacity.combined(with: .scale(scale: 0.96)) + ) + .onAppear { animatesSymbol = true } + } +} + +private struct DropTargetCardSurface: ViewModifier { + let cornerRadius: CGFloat + + @ViewBuilder + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius) + if #available(iOS 26.0, macOS 26.0, *) { + content + .glassEffect(.regular, in: shape) + .overlay { + shape.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1) + } + .shadow(color: .black.opacity(0.16), radius: 12, y: 5) + } else { + content + .background(.regularMaterial, in: shape) + .overlay { + shape.strokeBorder(Color.primary.opacity(0.10), lineWidth: 1) + } + .shadow(color: .black.opacity(0.16), radius: 12, y: 5) + } + } +} diff --git a/TFM/Features/AI/Markdown/MarkdownSoftBreaks.swift b/Frameworks/TermindPresentation/MarkdownSoftBreaks.swift similarity index 98% rename from TFM/Features/AI/Markdown/MarkdownSoftBreaks.swift rename to Frameworks/TermindPresentation/MarkdownSoftBreaks.swift index c59c50f9..e851bc98 100644 --- a/TFM/Features/AI/Markdown/MarkdownSoftBreaks.swift +++ b/Frameworks/TermindPresentation/MarkdownSoftBreaks.swift @@ -1,5 +1,4 @@ import Foundation -import TermindApplication /// Turns paragraph-internal newlines into hard breaks before parsing. /// @@ -29,9 +28,9 @@ import TermindApplication /// is not: **HTML blocks carry composed attachments verbatim** in the /// model-facing transcript. Editing a byte inside one would alter the context /// sent to the provider, so they are skipped wholesale. -nonisolated enum MarkdownSoftBreaks { +nonisolated public enum MarkdownSoftBreaks { - static func hardened(_ source: String) -> String { + public static func hardened(_ source: String) -> String { let lines = source.components(separatedBy: "\n") guard lines.count > 1 else { return source } diff --git a/Frameworks/TermindPresentation/TermindMarkdownParser.swift b/Frameworks/TermindPresentation/TermindMarkdownParser.swift new file mode 100644 index 00000000..c5ad6563 --- /dev/null +++ b/Frameworks/TermindPresentation/TermindMarkdownParser.swift @@ -0,0 +1,189 @@ +import Foundation +import MarkdownParser + +nonisolated public enum TermindMarkdownParser { + public static let allowedLinkSchemes: Set = ["http", "https", "mailto"] + public static let shellLanguages: Set = [ + "bash", "sh", "zsh", "shell", "console", "terminal", + ] + + public static func parse(_ source: String) -> [MarkdownBlockNode] { + let parsed = MarkdownParser().parse(MarkdownSoftBreaks.hardened(source)) + return sanitize(parsed.document) + } + + public static func sanitize(_ blocks: [MarkdownBlockNode]) -> [MarkdownBlockNode] { + blocks.rewrite { (inline: MarkdownInlineNode) -> [MarkdownInlineNode] in + switch inline { + case .link(let destination, let children): + guard isAllowedLink(destination) else { return children } + return [.link(destination: destination, children: children)] + case .image(_, let children): + // Never fetch a model-chosen URL while rendering a transcript. + // The image's alt text remains readable ordinary prose. + return children + default: + return [inline] + } + } + } + + public static func isAllowedLink(_ destination: String) -> Bool { + guard let scheme = URL(string: destination)?.scheme?.lowercased() else { + return false + } + return allowedLinkSchemes.contains(scheme) + } + + public static func isActionableShell(language: String?, code: String) -> Bool { + guard let language else { return false } + return shellLanguages.contains(language.lowercased()) + && !normalizedFenceCode(code).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + public static func segments( + from blocks: [MarkdownBlockNode], + allowsShellActions: Bool + ) -> [TermindMarkdownBlockSegment] { + var result: [TermindMarkdownBlockSegment] = [] + var markdown: [MarkdownBlockNode] = [] + + func flushMarkdown() { + guard !markdown.isEmpty else { return } + result.append(.markdown(markdown)) + markdown.removeAll(keepingCapacity: true) + } + + for block in blocks { + if allowsShellActions, + case .codeBlock(let language, let code) = block, + isActionableShell(language: language, code: code) + { + flushMarkdown() + result.append( + .shell( + language: language ?? "", + command: normalizedFenceCode(code) + )) + } else { + markdown.append(block) + } + } + flushMarkdown() + return result + } + + /// cmark includes the structural newline immediately before a closing + /// fence. It is not part of the command and must not make a one-line shell + /// suggestion look like a multi-line script to the execution guard. + private static func normalizedFenceCode(_ code: String) -> String { + code.hasSuffix("\n") ? String(code.dropLast()) : code + } + + public static func plainText(_ source: String) -> String { + parse(source) + .flatMap(plainText) + .joined(separator: " ") + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + } + + /// A portable single-line title that keeps inline emphasis and safe links. + public static func inlineTitle(_ source: String) -> AttributedString { + func inlines(_ nodes: [MarkdownInlineNode]) -> AttributedString { + nodes.reduce(into: AttributedString()) { result, node in + var value: AttributedString + switch node { + case .text(let text), .html(let text), .math(let text, _): + value = AttributedString(text) + case .code(let text): + value = AttributedString(text) + value.inlinePresentationIntent = .code + case .softBreak, .lineBreak: + value = AttributedString(" ") + case .emphasis(let children): + value = inlines(children) + for run in value.runs { + value[run.range].inlinePresentationIntent = (run.inlinePresentationIntent ?? []).union( + .emphasized) + } + case .strong(let children): + value = inlines(children) + for run in value.runs { + value[run.range].inlinePresentationIntent = (run.inlinePresentationIntent ?? []).union( + .stronglyEmphasized) + } + case .strikethrough(let children): + value = inlines(children) + for run in value.runs { + value[run.range].inlinePresentationIntent = (run.inlinePresentationIntent ?? []).union( + .strikethrough) + } + case .link(let destination, let children): + value = inlines(children) + if isAllowedLink(destination) { value.link = URL(string: destination) } + case .image(_, let children): + value = inlines(children) + } + result += value + } + } + func blocks(_ nodes: [MarkdownBlockNode]) -> [AttributedString] { + nodes.flatMap { node -> [AttributedString] in + switch node { + case .paragraph(let content), .heading(_, let content): [inlines(content)] + case .codeBlock(_, let content): + [inlines([.code(content.trimmingCharacters(in: .whitespacesAndNewlines))])] + case .blockquote(let children): blocks(children) + case .bulletedList(_, let items), .numberedList(_, _, let items): items.flatMap { blocks($0.children) } + case .taskList(_, let items): items.flatMap { blocks($0.children) } + case .table(_, let rows): rows.flatMap { $0.cells.map { inlines($0.content) } } + case .thematicBreak: [] + } + } + } + return blocks(parse(source)).enumerated().reduce(into: AttributedString()) { result, item in + if item.offset > 0 { result += AttributedString(" ") } + result += item.element + } + } + + private static func plainText(_ block: MarkdownBlockNode) -> [String] { + switch block { + case .paragraph(let content), .heading(_, let content): + [plainText(content)] + case .codeBlock(_, let content): + [content] + case .blockquote(let children): + children.flatMap(plainText) + case .bulletedList(_, let items), .numberedList(_, _, let items): + items.flatMap { $0.children.flatMap(plainText) } + case .taskList(_, let items): + items.flatMap { $0.children.flatMap(plainText) } + case .table(_, let rows): + rows.flatMap { row in row.cells.map { plainText($0.content) } } + case .thematicBreak: + [] + } + } + + private static func plainText(_ inlines: [MarkdownInlineNode]) -> String { + inlines.map { inline in + switch inline { + case .text(let text), .code(let text), .html(let text), .math(let text, _): + text + case .softBreak, .lineBreak: + " " + case .emphasis(let children), .strong(let children), + .strikethrough(let children), .link(_, let children), + .image(_, let children): + plainText(children) + } + }.joined() + } +} + +nonisolated public enum TermindMarkdownBlockSegment: Equatable, Sendable { + case markdown([MarkdownBlockNode]) + case shell(language: String, command: String) +} diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift index b301bc38..84af4164 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/TermindAgentTools.swift @@ -66,6 +66,11 @@ nonisolated public enum TermindAgentTools { presenterRegistry.presenter(id: id) } + public static func presenter(for descriptor: AgentToolDescriptor) -> AgentToolDetailPresenter? { + if let id = descriptor.presentation?.presenterID { return presenter(id: id) } + return legacyPresenter(forToolNamed: descriptor.name) + } + /// Cards written before presenter IDs were persisted carry only a tool name. /// /// Every built-in of that era used the `builtin.` namespace, so the name is diff --git a/Memory/APP_ICON.md b/Memory/APP_ICON.md index e6437787..77789f4c 100644 --- a/Memory/APP_ICON.md +++ b/Memory/APP_ICON.md @@ -1,30 +1,17 @@ # App icon -`Termind/Resources/AppIcon.icon` is the source of truth for the application icon. -Xcode recognizes it through the synchronized Termind folder and the existing -`ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon` setting. +`Termind/Resources/AppIcon.icon` is the source of truth and is selected by +`ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon`. -## Artwork and rendering +- `Assets/Artwork.png` is opaque, full-bleed, square 1024 x 1024 sRGB artwork. + Keep it unmasked: Icon Composer/Xcode owns platform padding and corner masks. +- The flattened artwork already contains its gradient and lighting. Keep glass, + specular, translucency, and group-shadow effects disabled, and do not restore a + competing PNG appiconset or hand-maintained generated sizes. +- Xcode produces the modern icon stack and compatibility `AppIcon.icns` for the + macOS 15 deployment target. -- `Assets/Artwork.png` is a full-bleed, opaque, 1024 × 1024 sRGB image. It preserves - the existing symbol, blue gradient, and baked-in lighting. Its outer background - was extended to remove the old transparent border and pre-rendered rounded rim. -- Keep the artwork square and unmasked. Do not add Dock padding or rounded corners - to this source image. Icon Composer and Xcode own the platform mask and padding. -- The image is intentionally flattened to retain the existing design. Additional - glass, specular, translucency, and group-shadow effects are disabled to avoid - applying a second set of effects to the artwork. -- Xcode compiles the modern icon stack and generates the compatibility `AppIcon.icns` - for the macOS 15 deployment target. Do not restore a competing PNG appiconset or - maintain the generated icon sizes by hand. - -## Verification - -Build using the pinned package versions. Inspect the compiled asset catalog for -an `AppIcon` icon stack containing opaque, full-canvas artwork, and inspect the -generated ICNS for a visible width of about 80.5% of its canvas (206 of 256 pixels). -Use a temporary bundle with a distinct identifier when checking system rendering; -`NSWorkspace` can otherwise return an older cached image after a rebuild. - -Check the actual TestFlight listing after uploading a new build. Local rendering -and successful compilation do not verify App Store Connect's icon processing. +Verify with pinned dependencies: inspect the compiled `AppIcon` stack for opaque +full-canvas artwork and ICNS visible width near 206/256 pixels. Use a temporary +bundle identifier to avoid `NSWorkspace` cache, then confirm the uploaded +TestFlight listing because local compilation does not verify App Store processing. diff --git a/Memory/APP_STORE_COMMERCE.md b/Memory/APP_STORE_COMMERCE.md index 93a43195..6318e480 100644 --- a/Memory/APP_STORE_COMMERCE.md +++ b/Memory/APP_STORE_COMMERCE.md @@ -1,101 +1,67 @@ # App Store commerce configuration -Termind Pro is a permanent, non-consumable purchase. The trial is a separate free, -non-consumable product. Neither product is a subscription, and the trial cannot -charge or renew automatically. +Termind Pro and its trial are separate non-consumables. The trial is free, lasts +14 days, never renews or charges, and does not support Family Sharing. ## Products | Field | Permanent Pro | Free trial | | --- | --- | --- | | Product ID | `com.akinokaede.termind.pro` | `com.akinokaede.termind.pro.trial` | -| Type | Non-Consumable | Non-Consumable | | Reference name | Termind Pro Lifetime | Termind Pro 14-Day Trial | -| Price | Keep the existing regional prices | Free / Price Tier 0 in every offered region | -| Family Sharing | Keep enabled | Disabled | -| English display name | Termind Pro | 14-day Trial | -| Simplified Chinese display name | Termind Pro | 14天试用 | +| Price | Existing regional prices | Free / Tier 0 in every offered region | +| Family Sharing | Enabled | Disabled | +| English name | Termind Pro | 14-day Trial | +| Simplified Chinese name | Termind Pro | 14天试用 | Trial descriptions: - English: `Try all Pro features free for 14 days.` -- Simplified Chinese: `免费试用Termind Pro全部功能14天,到期不会自动扣费。` +- Chinese: `免费试用Termind Pro全部功能14天,到期不会自动扣费。` -In App Store Connect, create the trial under Termind's In-App Purchases, using the -exact ID above. Add both localizations, configure free pricing and availability, -and supply a review screenshot showing the trial button, duration, restricted Pro -features, permanent unlock price, and no automatic charge. Keep Family Sharing off -for the trial. Submit the new IAP with the app version that supports it, following -App Store Connect's submission requirements for the app's current IAP status. +Create the trial in App Store Connect with the exact ID, localizations, free +pricing, availability, and a review screenshot showing the button, duration, +gated features, permanent price, and no automatic charge. Submit it with the +supporting app version and keep Family Sharing off. `StoreKit/Termind.storekit` +is local test data only; it cannot create or modify the online product. -`StoreKit/Termind.storekit` is only local test configuration. Editing it does not -create or change an App Store Connect product. Do not release the feature until the -online trial product is configured and available for the intended app release. +## App Review note -## Suggested App Review notes +The free non-consumable trial follows Guideline 3.1.1. Access expires 1,209,600 +seconds after the verified `originalPurchaseDate`; restore never restarts it. +Permanent Pro is a separate lifetime non-consumable and takes precedence. -Termind offers a 14-day trial through the free non-consumable in-app purchase -`com.akinokaede.termind.pro.trial` (14-day Trial), consistent with App Review -Guideline 3.1.1. The paywall displays the duration, the Pro features that require -purchase after expiration, and the localized permanent unlock price before the -trial can be claimed. The trial does not create a subscription and never charges -automatically. Access expires 1,209,600 seconds after the verified original purchase -date. Restoring the trial does not restart it. Termind Pro remains a separate -permanent non-consumable purchase with Family Sharing. +To demonstrate expiration, tap the Termind Pro heading seven times, open App +Review Demo, enter `TERMIND-REVIEW-2026`, and choose Show Expired Trial. Demo +mode can only deny access; exit it to return to real StoreKit state. -For expiration UI review, tap the Termind Pro heading seven times, open App Review -Demo, enter `TERMIND-REVIEW-2026`, and select Show Expired Trial. Exit Demo Mode to -return to real StoreKit access. This control only denies access; it never grants a -purchase or a trial. A verified permanent purchase overrides the demo. +## Release verification -## Local and Sandbox verification +Automated `StoreKitTrialTests` use `SKTestSession` and the local configuration. +The shared scheme normally uses real StoreKit. A hosted test may place an approved +transaction in history without delivering an update, so real listener delivery +must still be checked in Sandbox. -The normal shared Termind scheme continues to use real StoreKit. For manual local -simulation, explicitly select `StoreKit/Termind.storekit` in the scheme's Run -options and remove the selection when finished. The automated `StoreKitTrialTests` -use `SKTestSession` with this file directly, disable purchase dialogs, and clear -only local simulated transactions between tests. On the current macOS hosted test runner, -approved transactions can appear in history without a pushed update; the regression invokes the -production update handler with that actual verified result. Verify real push delivery in Sandbox. +With Sandbox testers, verify: -After the App Store Connect product is ready, verify with a Sandbox tester: +1. English/Chinese paywalls show 14 days, gated features, localized permanent + price, and no charge or renewal. +2. Claim, cancellation, restart, restore, and same-account cross-device behavior; + expiration must stay anchored to the original transaction. +3. Pending approval, metadata failure, purchase failure, restore failure, and + transaction-listener recovery without removing already verified access. +4. Permanent purchase during trial and restore; it must win and keep Family Sharing. +5. Day 7, just before day 14, and exact-expiration boundaries in automated tests. +6. Revoked trial history remains claimed. Nonzero price, wrong type, missing Pro + price, or trial Family Sharing must block purchase. -1. Open the paywall in English and Simplified Chinese. Confirm 14 days, Pro feature - restrictions, localized permanent price, and no automatic charges. -2. Claim the free trial. Confirm the system sheet says free and the app shows an - expiration 14 days after the original transaction. Cancel a separate attempt - using a fresh tester and confirm no access is granted. -3. Restart and restore with the same tester. Confirm the expiration is unchanged. - On another Mac, use the same App Store account; iCloud sign-in is not required - to determine trial eligibility. -4. Exercise pending approval, failed product loading, purchase failure, and - restore failure. Existing verified access must remain usable during metadata - or purchase failures. Resolve pending approval and confirm the transaction - listener updates access. -5. Buy permanent Pro during the trial and restore it. Confirm permanent access - takes precedence and Pro Family Sharing remains supported. -6. Use automated local date-boundary tests for day 7, immediately before day 14, - and the exact expiration instant. The App Review demo checks expired UI only; - it is not a substitute for these entitlement tests. -7. Verify revoked trial history remains claimed and does not create a fresh trial. - A price other than zero, a wrong product type, missing Pro pricing, or trial - Family Sharing being enabled must prevent trial purchase. +StoreKit history is the sole eligibility/start-date source. Previous local +seven-day trials are ignored and not migrated, so users without Pro may claim the +new IAP. A UserDefaults high-water mark keyed by original transaction only guards +ordinary clock rollback; it cannot grant access or prevent account changes. There +is no licensing server or device fingerprint, and Vault/Keychain sync is unrelated. -## Eligibility and compatibility - -Previous local seven-day trial records are ignored. Users who have not bought Pro -can claim the new 14-day IAP even if their old trial ended. No legacy records are -migrated or deleted. Vault CloudKit sync and user credential Keychain storage are -unrelated and remain unchanged. - -StoreKit is the source of purchase eligibility and the original start date. -A local UserDefaults watermark keyed by the original transaction ID only prevents -ordinary backwards-clock changes from reactivating an observed expired trial. It -cannot grant access. It is not tamper-proof and does not identify a person or stop -someone using another App Store account. No licensing server or device fingerprint -is introduced. - -References: [App Review Guideline 3.1.1](https://developer.apple.com/app-store/review/guidelines/#in-app-purchase), -[originalPurchaseDate](https://developer.apple.com/documentation/storekit/transaction/originalpurchasedate), -[currentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements), -[transaction history](https://developer.apple.com/documentation/storekit/transaction/all). +References: [Guideline 3.1.1](https://developer.apple.com/app-store/review/guidelines/#in-app-purchase), +[`originalPurchaseDate`](https://developer.apple.com/documentation/storekit/transaction/originalpurchasedate), +[`currentEntitlements`](https://developer.apple.com/documentation/storekit/transaction/currententitlements), +and [transaction history](https://developer.apple.com/documentation/storekit/transaction/all). diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index 650a912e..5ff3e366 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -1,916 +1,363 @@ # Termind architecture -Termind uses one Xcode project with two app targets (`TFM` and `TFI`), six -static frameworks, three test targets, and the macOS-only MCP bridge. AgentKit -remains a versioned remote SPM dependency developed in the sibling repository. +Termind is one Xcode project with macOS (`TFM`) and iOS/iPadOS (`TFI`) apps, +shared static frameworks, platform test targets, and a macOS-only MCP bridge. +The agent runtime is the separately versioned `AgentKit` package. -## Dependency direction +## Modules and dependency direction -- `Frameworks/TermindDomain` owns platform-independent values and rules. It may - use AgentKit values, Foundation, and CryptoKit, never UI or concrete I/O. -- `TermindApplication` owns repository ports, Vault/AI feature models, secret interfaces, - SSH configuration/preparation, SSH import preview, remote-file-system ports and policy, the outbox runner, and - reconnect policy; it depends on Domain and AgentKit. -- `TermindInfrastructure` owns the memory repository, metrics parsers and StoreKit access. - It depends on Application, Domain and Persistence. CloudKit Vault/Settings coordinators live here and share one implementation across both apps. -- `TermindRemoteInfrastructure` owns SSH/SFTP, key material, Keychain and remote sampling. - It also owns scoped SSH import reads, forwarding and transfer execution. - Its C libraries are linked only through this target. Keeping it outside the portable - FrameworkTests dependency graph avoids Xcode promoting OpenSSL to an empty dynamic - package wrapper during multi-host testing. It depends on Infrastructure, Application and Domain. -- `TermindPresentation` owns reusable SwiftUI content. It depends on Application and Domain, - never on app targets, platform UI frameworks, transports or persistence. Ghostty's terminal/theme products are - presentation dependencies: the shared theme catalog and `TerminalPreferences` application bridge live here, while - terminal transport and native surface lifetime remain app-owned. -- `TermindPersistence` owns every SwiftData model, frozen historical schema, - migration plan, and the shared container factory. It depends on Domain, - AgentKit, Application repository interfaces, and SwiftData, never an application target. -- `TFM` retains desktop feature models, adapters, views and application/window - orchestration. `TFI` owns its native mobile entry point and scene navigation. - Platform code can consume frameworks; frameworks cannot consume either app. +- `TermindDomain` owns platform-independent values and rules. It may use + Foundation, CryptoKit, and AgentKit values, but never UI or concrete I/O. +- `TermindApplication` owns repository ports, feature models, connection policy, + SSH preparation/import previews, remote-file-system interfaces, sync/outbox + coordination, and other use cases. It depends on Domain and AgentKit. +- `TermindInfrastructure` owns in-memory repositories, metrics parsing, StoreKit, + and the shared Vault/Settings/Metrics CloudKit coordinators. It depends on + Application, Domain, and Persistence. +- `TermindRemoteInfrastructure` owns SSH/SFTP, Keychain, key material, remote + sampling, forwarding, transfers, and scoped SSH import reads. Its C libraries + remain outside the portable framework-test graph. It depends on Infrastructure, + Application, and Domain. +- `TermindPresentation` owns reusable SwiftUI content, presentation policy, + Ghostty themes, and the `TerminalPreferences` bridge. It depends only on + Application and Domain; native surfaces, transports, and lifetimes stay in apps. +- `TermindPersistence` exclusively owns every SwiftData model, frozen V1-V15 + schema, migration plan, and container factory. It never depends on an app target. +- `TFM` and `TFI` own platform UI, adapters, and lifecycle orchestration. Both use + `App`, `Application`, `Design`, `DesignSystem`, `Features`, `Infrastructure`, + `Platform`, and `UI`; frameworks never import either app. -Both app targets use the same top-level layer vocabulary: `App`, `Application`, -`Design`, `DesignSystem`, `Features`, `Infrastructure`, `Platform`, and `UI`. -TFI feature code is grouped by capability inside that structure; production SSH -construction lives in Infrastructure and reusable mobile chrome lives in UI. +Frameworks expose explicit public APIs and do not re-export imports. Apps import +canonical owner modules directly, without forwarding aliases or `Shared` type +names. There is no generic Core module. -The frameworks have explicit public boundaries and do not re-export imports. -App targets import framework owners directly. They do not recreate framework -names with forwarding type aliases, and framework types do not use `Shared` as -a namespace: the module and responsibility-specific name express ownership. -`Host` is explicitly imported from Domain where Foundation's same-named type -would otherwise make lookup ambiguous. Presentation of session-context chips -belongs to the desktop feature extension, not the domain value. +Persistence preserves entity identities, fields, defaults, and historical schema +semantics even when code moves. Original-module V1/V14 fixtures guard migration. +`makeContainer` fixes the complete schema and migration plan; each process shares +one container across its scenes. Store failures never silently recreate data. +macOS retains the existing `Termind` store location; mobile uses Application +Support and an in-memory store for tests. Both apps use the same CloudKit container +and bundle-derived Keychain access group, with target-specific signing. -All SwiftData models and V1–V15 schemas have one definition in Persistence, -including sync journals' records and secret-location metadata. Platform adapters -can still access public persistent records during incremental extraction, but -feature views cannot access SwiftData or construct transports. Neither moving a -model nor renaming a target authorizes changing persisted entity identities, -fields, defaults, or historical schema meanings. Original-module V1/V14 database -fixtures test this boundary independently of newly generated stores. +Shared localized resources are copied into each app bundle. Platform icons and +metadata remain app-owned. Framework, macOS, and mobile tests stay separate; +architecture tests fail closed when an expected production directory is absent or +empty. -`TermindPersistence.makeContainer` fixes the complete schema and migration plan. -Each app process owns one container shared by its scenes. The desktop keeps the -`Termind` store configuration and its existing local location; mobile uses an -Application Support store with the same schema and an in-memory container for tests. Store failures -never silently recreate a database. Keychain access is shared and explicitly configured -by each app. Both apps wire the shared CloudKit coordinators into the persisted journals; -TFI/TFI.entitlements and TFM/TFM.entitlements use the same CloudKit container and -bundle-derived Keychain access group. Signing environments remain target-specific. +## State, navigation, and ownership -Shared resources are copied into each app bundle. Localized framework strings -resolve against the host bundle; platform icons and metadata remain app-owned. -Build settings live in the single project, with SDK and deployment choices on -targets. The apps link static frameworks without embedding them dynamically. +- Repository snapshots and preference models are application-wide. A window owns + its shell, quick-open, search, navigation, and context-panel state. +- Desktop navigation starts with Vault and Assistant, places Metrics above Vault + libraries, appends dynamic SSH/SFTP Workspaces, and ends with Settings. Vault + owns Hosts, Keychain, Snippets, Known Hosts, Welcome, and Host Editor routes. +- Assistant conversations are owned by `AssistantLibraryModel`; they are not + Workspace panes. A Workspace owns SSH/SFTP/serial panes and any session-scoped + conversation. A session owns live transports and terminal/browser state. +- Settings data is app-wide but its navigation is window-scoped. Opening Settings + remembers and later restores the replaced destination. +- Feature-specific Application models expose narrow intent surfaces even when they + share one Vault snapshot. `VaultStore` owns the transaction; sessions use + `RemoteSessionHostStore`, never the full mutation API. +- Runtime connection state is never written onto domain records. Saved repository + data is persistent truth; device-local enabled forwarding intent is UserDefaults + state and is excluded from Vault/Metrics/Settings sync. +- Serial sessions are temporary and local. IOKit/termios objects remain in + Infrastructure; serial paths and parameters are neither persisted nor exposed + as Agent hosts. -`FrameworkTests`, `TFMTests`, and `TFITests` have separate root directories. -Framework tests run on macOS and iOS without importing either application. -Architecture tests enforce directory and framework boundaries and fail closed -when an expected source directory is missing or empty. +Known Hosts is independent of Host records: editing or deleting a Host does not +change trust. Plain and OpenSSH `|1|` hashed endpoints share one matcher; hashed +records keep their wire `hostname`, and their separate port field is ignored. -There is no generic Core directory. Types belong to their domain, application, -infrastructure, persistence, or presentation responsibility. +SSH imports stage selectable public metadata in a window-owned model while the +actor reader holds private keys until commit/cancel. Picking and drag-and-drop use +the same scoped validation; config parsing never executes commands. Commit writes +new credentials before one Vault save, cleans them up on failure, never overwrites +existing records, and never clears an existing trust mismatch. -## State lifetimes +App appearance is a global UserDefaults preference applied by the macOS app +delegate through `NSApplication.appearance`; following System clears the override. +Opening at login reflects `SMAppService` on every Settings visit rather than a +mirrored preference. A quiet login hides the app without suppressing its window. -- Repository snapshots are application-wide and shared by every window. -- A window owns one top-level shell, quick-open, and context-panel state. -- Metrics is a fixed window destination below Welcome and above the Vault group. -- The shell always starts with `Vault` and `Assistant`; Settings is the final Navigator - page after the dynamic SSH/SFTP Workspaces. -- Vault owns its navigation column, including quick-connect Recents, and routes - for Hosts, Keychain, Snippets, Known Hosts, Welcome, and Host Editor. These routes never - enter the Workspace deck. -- Known Hosts is an independent Vault card manager below Snippets. Host edits and - deletion never move or remove trust records. Plain endpoints and OpenSSH `|1|` - hashed endpoints use one matcher across all SSH callers. Hashed tokens retain - their original `hostname` wire field; their `port` field is ignored because the - port is part of the hashed input. No Host record is required to retain trust. -- SSH folder imports stage selectable public metadata in a window-owned model; - their actor-owned reader retains private keys until commit or cancellation. - File picking and native drag-and-drop feed the same source validation and preview. - File access is scoped to explicitly selected directories/files. Config parsing - never executes commands. New credentials are written before a single Vault - snapshot save and removed on failure. Existing records are never overwritten. - Imported trust does not clear existing mismatch warnings. -- Assistant owns its conversation list through `AssistantLibraryModel`; conversations are - not panes and do not participate in Workspace drag, split, close, or reconnect - lifecycles. -- A workspace owns SSH/SFTP/serial panes plus its session-scoped assistant - conversation. Workspace content is full width and has no left sidebar. -- Session models own live SSH/SFTP/serial resources and terminal/file-browser state. -- Command Generator's tool-free model request service, draft protocol and stale-generation lifecycle live in - Application and are shared by both apps. Each platform session supplies its live terminal context and insertion - closure. Generated commands are bracketed-pasted into the current edit buffer and are never submitted automatically. -- `TerminalCommandDraftController` owns the shared draft request protocol, timeout, - cancellation and bracketed-paste payload. `CommandGenerationCoordinator` owns - the shared model timeout, stale-snapshot gates and transient presentation state; - platform sessions provide only their live context, validity check and insertion closure. -- `TerminalFindController` owns the retained query, visibility, Ghostty search/navigation bindings, and focus return - for desktop SSH, desktop Serial, and mobile SSH sessions. -- A mobile SSH session delivers Shell Integration and transport-exit callbacks through one session-scoped ordered - consumer on MainActor. Reconnect, background expiration, and explicit close terminate that consumer; callbacks - from an older connection generation cannot reorder prompt state or mutate its replacement session. Authenticated - integration installation and command-finished events are prompt-boundary readiness evidence on mobile; the - following prompt marker is idempotent, so losing or duplicating it cannot strand or cancel Command Generator. -- SSH, SFTP, and mobile Workspaces share `RemoteSessionConnectionController` for - first-connect and recovery state, retry timing, Journey ownership, and host-key - decisions. Interactive host-key approval is part of the live transport attempt: - every route node keeps the same socket and libssh2 session from handshake through - approval and authentication. Production callers must never open a separate - unauthenticated discovery connection before the real connection. `SSHShellIntegrationEvent` - is a Domain session value emitted by the Remote Infrastructure transport. -- `RemoteFile` is a Domain value and `RemoteFileSystem` is the Application port for listing and mutating remote - entries. The libssh2-backed `SFTPClientProtocol` refines that port in Remote Infrastructure. File-kind symbols and - localized kind labels belong to Presentation. Mobile directory path, listing, and stale-request state live in - `MobileSFTPSessionModel`; `MobileWorkspace` injects the connected file-system port and app-wide SFTP History facade, - and reacts to connection loss. -- Connection Journey's header, ordinary status, route graph, decision forms, and reconnect countdown live in - Presentation. Desktop and mobile retain their own log renderer, failure actions, background, and keyboard bridge. -- `RemoteSystemInfo.distributionDetection` is the only mapping from untrusted remote metadata to a persisted - `DetectedDistribution`; it distinguishes incomplete Linux metadata from a conclusive non-Linux reset. -- Terminal upload availability and notice semantics live in Application/Presentation. Desktop and mobile keep - separate upload executors because their staging and client-ownership lifecycles differ. -- A clean mobile SSH shell exit closes its Workspace through the app-owned lifecycle, while transport failures keep - the Workspace alive for automatic recovery. Removing the selected Workspace also clears the mobile navigation - destination so the UI returns to the Workspace list instead of retaining an ended terminal surface. -- Bash's DEBUG fallback passes the pending `BASH_COMMAND` into the history hook and ignores Termind's own - `__termind_*` helpers. In particular, the command-generator draft/clear key bindings are protocol traffic, not - executed user commands: they neither create History rows nor transition the prompt to busy. -- Settings uses app-wide preference storage but window-scoped navigation. It opens as a - top-level window destination, remembers the content it replaced, and restores that - destination when its index page goes back. -- App appearance is an app-wide UserDefaults preference, defaulting to the system. - The app delegate owns `AppAppearanceController`, which applies changes through - `NSApplication.appearance` so existing and new windows, native controls, and terminal - surfaces inherit one appearance without rebuilding sessions. Following the system - clears the override; explicit light/dark choices reuse the corresponding terminal theme. -- Opening at login is `SMAppService`'s registration, not a preference. The user can revoke - it in System Settings without telling the app, so `LoginItemController` reads the system - status every time Settings is shown rather than mirroring it into `UserDefaults`. Only - *how* that launch behaves is stored here. A quiet login launch hides the app rather than - suppressing its window, because a quiet login launch must not present an empty foreground window. +## Remote sessions, terminal, and files -Repository data is persistent truth. Runtime connection state is kept in a -connection registry and is never persisted onto domain records. Port forwarding's -Enabled choice is a device-local `UserDefaults` intent rather than runtime state: -it is not included in Vault/Metrics/Settings sync, while macOS restores it at launch and -iOS restores it whenever the app becomes active after background suspension. +SSH, SFTP, mobile Workspaces, and Metrics share connection preparation and recovery +policy. Interactive host-key approval remains inside the actual transport attempt: +the same socket/libssh2 session continues from handshake through approval and +authentication; callers never perform a separate discovery connection. +Generation tokens prevent callbacks, credential results, and retry timers from a +retired connection mutating its replacement. -SSH command history is created only after an authenticated shell-integration -start event. A matching completion event enriches that row with exit status and -the duration measured by the remote shell; both remain optional so interrupted -commands, old protocol events, and history migrated from older versions preserve -an honest unknown outcome. Duration is never inferred from client receive time, -because SSH buffering can deliver start and completion events together. +`RemoteFile` is a Domain value, `RemoteFileSystem` the Application port, and the +libssh2 `SFTPClientProtocol` its Remote Infrastructure implementation. File symbols +and localized kind names belong to Presentation. `MobileSFTPSessionModel` owns +path, listing, navigation history, filters, mutations, and stale-request state. -SFTP History is device-local persisted truth shared by the desktop and mobile -apps. A directory is recorded only after its listing succeeds and survives the -current connection-generation gate. A file is recorded only after its download -completes successfully. Failed, cancelled, missing, type-changed, and stale -operations do not create or refresh History rows. +`TerminalCommandDraftController` owns draft requests, timeout, cancellation, and +bracketed-paste payloads. `CommandGenerationCoordinator` adds model timeout, +snapshot freshness, and presentation state. Platforms provide live context, +validity, and insertion only; generated commands are inserted but never submitted. +`TerminalFindController` is shared by desktop SSH/Serial and mobile SSH. -Feature views receive aggregate-facing Application Models rather than a generic -store API: `HostLibraryModel`, `CredentialLibraryModel`, `KnownHostsLibraryModel`, `SnippetLibraryModel`, -`ForwardingLibraryModel`, `TransferLibraryModel`, `AIConfigurationModel`, -`SkillLibraryModel`, `CommandHistoryModel`, `SFTPHistoryModel`, and `ToolAuditModel`. Aggregates that share one persisted -vault snapshot still expose separate intent surfaces, so a feature cannot gain -unrelated write operations merely because its records are saved together. +Mobile SSH sends shell-integration and transport-exit callbacks through one ordered, +session-scoped MainActor consumer. Close, reconnect, or background expiration ends +it. Authenticated installation and command completion establish prompt readiness; +following prompt markers are idempotent. Bash DEBUG fallback passes `BASH_COMMAND` +and ignores Termind's `__termind_*` helpers so draft protocol traffic never creates +History rows or marks the prompt busy. -The shared Vault transaction is isolated behind `VaultStore`. Feature facades -keep their backing aggregate private, and Terminal/SFTP sessions depend on the -narrow `RemoteSessionHostStore` port rather than the complete Vault mutation -surface. +Command History begins only after an authenticated start event. Completion may add +remote exit status and duration; both remain optional and duration is never inferred +from client receive time. History is capped at 1,000 rows per host. -Serial sessions are temporary and local: IOKit/termios discovery and descriptors -stay in Infrastructure, parameters live only with the open pane, and no serial -path is persisted or exposed as an Agent host. +SFTP History is device-local persisted truth shared across apps. Directories are +recorded only after a fresh successful listing and files only after download. +Failed, cancelled, stale, missing, or type-changed operations do not update it. +Transfers own dedicated connections and outlive browser closure; host deletion and +background expiration cancel them, and nothing is silently replayed. -Window navigation has one Navigator plus three tool sidebars: Assistant, -Snippets, and History. History is unavailable while a serial pane is focused, -because serial sessions have no command, file, or connection history; entering -serial focus from History falls back to Snippets. Dynamic SSH/SFTP containers -are consistently named Workspaces; “tab” is reserved for keyboard or native UI -tab semantics. Vault selection belongs to `VaultPageStates`, not to sidebar state. +## Callback isolation -## Speech authorization and callbacks +Speech services and coordinator state are MainActor-isolated, but authorization +callbacks may arrive on any queue. A nonisolated factory creates an explicitly +Sendable continuation callback; dispatching from an actor-inherited closure would +trap before its body runs. The coordinator rechecks its recording-session token +after authorization so a late result cannot revive a cancelled session. -Speech service and coordinator state belong to MainActor. System authorization -callbacks can arrive on arbitrary queues; `SFSpeechRecognizer.queue` controls -recognition callbacks, not authorization callbacks. The speech authorization -handler is created in a nonisolated factory as an explicitly Sendable closure -that only resumes its checked continuation. The awaiting service returns to -MainActor to interpret the result. Moving work to the main queue inside an -actor-inherited callback is too late to avoid its entry isolation check. - -Authorization does not own a recording session: the coordinator rechecks its -session token after waiting, so a cancelled request's late result cannot start -recording or affect a newer session. +iOS Core Audio taps follow the same rule: a nonisolated factory creates the realtime +tap block, which calls only Sendable buffer/level handlers. Reconnect timers and +metrics workers also enter explicit MainActor methods rather than relying on +inherited task isolation; remote I/O stays behind asynchronous/detached boundaries. ## Agent runtime and security -The runtime is `AgentKit`, a separate package with no dependencies and no -knowledge of hosts, transports, or SwiftUI. What Termind keeps is everything -that reaches a machine or a person: the SSH and SFTP tools, the connection pool, -the Vault resolver, the SwiftData store, and every view. +`AgentKit` owns the provider adapters, loop, approval gate, MCP client, and built-in +host-agnostic tools. Termind owns everything that reaches its machines or people: +SSH/SFTP tools, connection pools, Vault resolution, persistence, policy, and UI. +The additive seams are: -The two meet at four seams, all additive: +- `AgentToolCatalog.registry(builtIn:additional:)`: AgentKit groups first, then + `TermindAgentTools`; Termind and remote MCP tools cannot shadow built-ins. +- `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; + stable prompt prefixes preserve provider caching. +- `TermindAgentPresentation`: Termind-only card values whose stable raw values keep + persisted cards decodable. -- `AgentToolCatalog.registry(builtIn:additional:)`. The package's built-in tools - are selected by `AgentToolGroup` — scratch, web, user interaction, planning, - tasks, skills, MCP — and `TermindAgentTools` supplies ours after them, so a - Termind name can never shadow a built-in and a remote MCP name can shadow - neither. -- `AgentToolServices`, a typed bag a run carries to the tools it registered. - `AgentSSHInteraction` travels in it, which is what keeps credential and - host-key prompts out of the package's own protocols — and, structurally, off - `AgentEventChannel` and out of durable history. -- `TermindAgentPolicy`, which owns the texts and judgements that are ours: the - system prompt, the plan contract, and the `ssh_exec` rule injected into - `AgentPlanModeHook.hostDecision`. The package ships neutral defaults for each - and Termind's stay byte-identical to what shipped, because every provider - cache is a prefix cache. -- `TermindAgentPresentation`, which extends the package's open card vocabularies - with the values only we need — `sshConnecting`, `remoteFile`, and the rest. - Their raw values are unchanged, so cards persisted before the split still - decode and still render. +`AgentRuntime` is an actor. `AgentContextPipeline` shapes only model requests; +stored history remains complete. `AgentTurnDriver` streams a turn, +`AgentToolScheduler` runs its batch, `AgentToolExecutor` performs schema validation, +preflight, card creation, hooks, authorization, then execution, and +`AgentEventChannel` serializes UI and durable events. Provider adapters include +OpenAI Responses/Chat Completions, Anthropic Messages, and Gemini. Models without +tool capability receive no tools; only recognized chat-purpose models appear in +conversation pickers, and unknown purpose values safely default to chat. -`AgentRuntime` is an actor holding a run's state — transcript snapshot, tool -registry, steering queue, secret broker — and the loop over it. Each phase of -the loop is a separate value it drives: +A batch is concurrent only if every call is parallel. Preflight may tighten this: +`ssh_exec` is parallel only when local command classification proves it safe; MCP +destructive hints or Ask First make a call sequential. Results always enter the +transcript in model order, and every call gets an answer even after cancellation or +refusal. Approvals/questions are queued one at a time; state derives from outstanding +work. Authorization is a structural executor stage and cannot be removed by hooks. -- `AgentContextPipeline` rewrites what is sent at the model boundary: tool - results reordered to match the calls they answer, orphaned results dropped, - older results trimmed head-and-tail, session context inserted in front of the - turn it describes. It shapes only the request. Run snapshots and stored - history keep everything. -- `AgentTurnDriver` streams one assistant turn and assembles its tool calls. -- `AgentToolScheduler` plans and runs the turn's batch. -- `AgentToolExecutor` takes one call through schema validation, preflight, the - card, hooks, authorization, and execution, in that order. -- `AgentEventChannel` is the single ordered path for events, fanning out to the - UI and to a serial durable recorder. +Permission modes share all validation: -OpenAI Responses, OpenAI Chat Completions, Anthropic Messages, and Gemini are -adapters at the LLM boundary. Models without tool capability receive no tools. -Stop cancels the active stream and actions that have not started; steering -messages are consumed at the next turn boundary. +- Ask for approval allows proven `locallyReadOnly`/`locallyContained` work and asks + for everything else. +- Approve for me sends other actions to Security Review; missing credentials, + timeout, transport failure, or malformed review fails closed and is recorded. +- Full access skips action approval/review but retains host-key, secret, schema, + path, fetch, sandbox, and capability boundaries. -Model purposes are `chat`, `embedding`, `rerank`, `image_generation`, and -`video_generation`. Only recognised purpose values from model discovery are -asserted as reported metadata. Unknown values, including Anthropic's `model` -object discriminator, leave catalog and name inference available and otherwise -default to chat. Only chat models enter conversation pickers. Persisted unknown -purpose strings also default to chat; there are no legacy purpose aliases. +Skills are trusted procedures only because users install them through Settings; +tools, MCP servers, and fetched pages cannot write or enable them. `allowed-tools` +and MCP annotations never authorize actions. The model receives enabled names and +descriptions at the context boundary, then loads bounded bodies through the +read-only `load_skill`; downstream reads remain untrusted. The enabled set is fixed +per run. Built-ins seed once, deleted built-ins stay deleted, and reset replaces the +library only after confirmation. -A turn's tool calls run concurrently only when every call in it is marked -parallel; one sequential call makes the whole turn serial rather than being -fenced into its own segment. Concurrency is declared per tool and may be -overridden per call by preflight — `ssh_exec` is parallel exactly when -`CommandRiskClassifier` proves the command safe, the same evidence that decides -its safety. MCP tools are parallel by default; a server's destructive hint or -Ask First makes them sequential, so remote metadata can only tighten -concurrency, never grant it. Whatever the order of execution, results reach the -transcript in the order the model asked for them, and every call is answered -even when the turn is cancelled or refused — an assistant turn with unanswered -tool calls is rejected outright on the next request. +Tools never receive the interactive PTY. SSH exec uses a dedicated channel and file +operations use run-scoped SFTP. User-entered secrets are single-use handles bound to +run/tool/host/purpose; plaintext never enters model input, reviews, events, logs, or +SwiftData. Fetch permits proven public HTTP(S) reads, revalidates DNS on every +redirect, reauthorizes entry into a restricted origin, follows explicit headers like +`curl -H -L`, and redacts sensitive values. Remote MCP metadata can tighten but never +relax local safety. -Approvals and agent questions are queued and presented one at a time. Run state -is derived from what is outstanding rather than asserted by whichever call moved -last. - -Extension points, all additive: `AgentToolCatalog` registrations for tool definitions, -`AgentToolServices` for what a host lends them, `AgentLoopHook` for -`willExecute` / `didExecute` / `shouldStop`, `AgentContextTransforming` for the -model boundary, `AgentModelStreaming` for providers. Authorization is deliberately not among them — `AgentApprovalHandling` -is a structural stage of the executor that no configuration can remove, and -hooks run on either side of it. - -Skills are the one exception to "tool output is data". A skill is a Markdown -procedure the user installed through Settings; only `SkillLibraryModel` writes -one, so no tool, MCP server or fetched page can add, edit or enable a skill. -That provenance is what lets `load_skill` return a body outside the -`` framing every other built-in result carries, and the system -prompt states the boundary: the instructions may be followed, whatever they then -cause to be read comes back through the ordinary tools and is untrusted as usual. -A skill authorizes nothing — imported `allowed-tools` is recorded and never -honoured, on the same footing as an MCP annotation. The catalog the model sees is -names and descriptions only, injected at the model boundary by -`AgentSkillCatalogInjection` rather than into the system prompt, for the -prefix-cache reason `AgentSessionContextInjection` states; bodies arrive only -through `load_skill`, which is `.locallyReadOnly` and therefore allowed in every -permission mode and while planning. The enabled set is snapshotted when the run -starts, like `isPlanning`, and is empty for a model without tool capability, so -the catalog never names a tool the run was not given. A loaded skill is exempt -from `AgentToolResultTrimming` — it is the procedure still being followed rather -than output already reasoned about — and bounded instead by -`AgentSkill.maximumBodyBytes`. `AgentSkillLibrary` ships starter skills that are -copied in once, tracked by a generation mark so a deleted one never returns; the -only way back is `resetToBuiltInSkills`, which replaces the library wholesale and -is confirmed before it runs. - -Tools never receive a reference to the interactive PTY. Server commands use a -dedicated SSH exec channel and file operations use run-scoped SFTP clients. -Vault secrets are resolved behind `AgentHostResolver`. User-entered secrets are -single-use handles bound to run, tool, host, and purpose; plaintext is passed -directly to the executor and is excluded from model input, reviews, events, -logs, and SwiftData. Fetch treats public HTTP(S) reads as locally read-only and -routes private or otherwise non-public unicast targets through the active -permission policy. DNS is revalidated on every redirect; a redirect that first -enters a restricted origin pauses for another authorization decision. Explicit -request headers follow every redirect, including cross-origin redirects, to -match `curl -H -L`; sensitive values are redacted from review, UI, and durable -history. MCP discovery retains schemas and annotations, but remote annotations -are risk hints rather than proof of safety. - -`AgentScratchWorkspace` is a per-conversation staging directory under Application -Support, inside the sandbox container, named for the app that owns it -(`TermindAgentPolicy.scratchDirectoryName`) and deleted with the conversation -that owns it. -Paths are relative, `..` is refused rather than resolved, symlinks are refused even -when they point back inside, and every resolved path is proven to still be under the -scope; writes are bounded per file, per conversation, and by entry count. It is an -actor, so it is also the per-path mutation queue — a read and a write of the same -file in one batch serialize there rather than by taking the whole turn serial, which -is why every scratch tool is parallel. `scratch_pull` and `scratch_push` move bytes -between a host and the workspace without the bytes entering the transcript, so a -remote file can be pulled, edited and pushed back without a copy of it crossing the -model boundary in either direction; `scratch_edit` returns a unified diff rather than -the new file for the same reason. `scratch_search` recursively searches UTF-8 content -inside that same scope and returns bounded matching-line previews, so locating a small -region does not require reading a large staged file into the transcript. `scratch_pull` and `scratch_push` are Termind's rather than the package's, -precisely because they are the two ends that touch a host. The SFTP tools are -unchanged and know nothing about the workspace. Because a push is approved by -filename rather than by content, its -preflight puts the size, digest and a redacted head into the approval reasons, and -binds the approval to that digest through execution-only metadata so a file rewritten -while the user was deciding is refused rather than written. - -The permission modes share structural validation but differ at the action gate. -Safety is a property of the call, established locally: `locallyReadOnly` for calls -proven to change nothing, `locallyContained` for calls that change only storage the -app owns, and `requiresAuthorization` for everything else. The first two are allowed -in every mode — a staging area that has to be approved into is not a staging area, -and dialogs nobody can act on teach the reader to click through the one that matters. - -- Ask for approval executes locally proven read-only and contained calls and - presents every other action to the user. -- Approve for me sends those same non-read-only actions to Security Review. - Missing credentials, timeout, transport errors, or malformed review JSON fail - closed and are persisted as review failures. -- Full access skips action approval and Security Review while retaining host-key, - secret, schema, host/path, fetch protocol/header/address-classification, - sandbox, and tool-capability boundaries. +Each conversation has a bounded `AgentScratchWorkspace` under app-owned Application +Support. It rejects absolute escape, `..`, and symlinks, proves resolved containment, +and serializes mutations per path. Pull/push move remote bytes without putting them +in the transcript; these host-touching endpoints remain Termind-owned. Edit returns a +diff and search bounded previews. Push approval is bound to size/digest and includes +a redacted head, so changed content is refused. Agent conversations, runs, visible messages, tool calls/results, and reviews are -stored in SwiftData after truncation and redaction. Launch marks unfinished runs -`interrupted`; history is restored but remote actions are never resumed or -replayed. +stored after truncation/redaction. Launch marks unfinished runs interrupted and never +replays remote actions. Restore materializes completed results missing from the last +snapshot; remaining unanswered calls receive an explicit unknown-outcome repair so +the model cannot assume an action was never dispatched. -A process that dies mid-batch — force quit, crash, the OS reclaiming the app — -leaves tool calls that nothing answered, which the next request would be -rejected for. Restore closes that in two steps. Results are recorded as each -tool finishes but their transcript rows wait for the run's next snapshot save, -so `loadConversations` first materializes any recorded result whose transcript -row was never written. Whatever is still unanswered is answered at the model -boundary by `AgentUnansweredToolCallRepair`, which says the outcome is unknown -rather than that the tool did not run: the process died after the command was -dispatched, and a model told otherwise would repeat it. +## Pro access and review invitations -## Pro purchases and trial access +`ProAccessController` owns application-wide StoreKit state. Permanent Pro and the +14-day free trial are separate non-consumables; only Pro supports Family Sharing. +Verified StoreKit history, including revoked trial history, determines whether the +trial was claimed. It expires exactly 14 x 24 hours after `originalPurchaseDate`, +never restarts on restore, and always yields to a valid permanent entitlement. +Purchase metadata/activity never replaces existing access. -`ProAccessController` owns application-wide StoreKit access and purchase activity. -The permanent non-consumable Pro product supports Family Sharing; the separate, -zero-price non-consumable trial does not. Trial eligibility comes from verified -StoreKit history, including revoked purchases, rather than current entitlements -alone. Resolution also includes the verified purchase/update being delivered because history caches may -lag behind that result. Concurrent refreshes await the latest resolution before purchase preflight proceeds. -The trial expires exactly 14 × 24 hours after `originalPurchaseDate`; -restores and repeated transactions never restart it. A valid permanent entitlement -always wins. Product metadata loading is independent of entitlement resolution, -and purchasing/pending approval does not replace existing access with an operation -state. The trial purchase path checks zero price, product type, sharing settings, -and availability of the permanent price before invoking the App Store. +Domain's `ProTrial` contains date/history rules only. Infrastructure's per-original- +transaction `TrialClock` watermark can shorten an observed trial after clock rollback +but cannot grant eligibility. Legacy local trial proofs are ignored. Expiration is +checked on activation and at most minutely while active; current-launch workspace +composition is not dismantled, but a fresh launch receives no stale grant. Review +demo mode can deny access for presentation only. -Domain's `ProTrial` contains only date and history rules; StoreKit stays outside -Domain. Infrastructure's `TrialClock` persists a per-original-transaction maximum -observed date in UserDefaults. This best-effort rollback guard can only shorten a -verified trial and is never a second source of purchase eligibility. Legacy local -proofs, trial Keychain keys, and trial CloudKit records are neither read nor written; -previous trial users may claim the new IAP. Vault sync and credential storage remain -separate from commerce. - -Trial expiration is rechecked on app activation and at most every minute while an -active trial is observed. Expiration disables gated features, but previously granted -workspace composition remains available for the current launch so an existing -workspace is not dismantled. A fresh launch has no such workspace grant. The review -demo only simulates expired access and cannot grant paid or trial access. +One app-wide `AppReviewController` stores device-local first-use/active-day/version/ +attempt history, never syncs it, and never claims a review was shown. Automatic +requests require seven elapsed days, three active dates, and five foreground +workspace minutes, then a three-second quiet transition to a Vault list or Settings +index/App page. Input, navigation, focus loss, sleep, or modal presentation cancels +the attempt. Attempts are persisted before StoreKit, limited to once per marketing +version and a 120-day cooldown; manual review restarts the cooldown. Tests and +screenshot launches disable live requests. ## Server metrics -Debug screenshot launches inject a frozen `ServerMetricsModel` into AppModel. -Its in-memory preferences never read or write saved monitor configuration, and -it cannot start samplers or host-key discovery. The metrics overview and detail -fixtures render the production views using deterministic, fictitious data. -Screenshot launches use the production WindowGroup with restoration disabled, -injecting only the demo model, language, and navigation state. There is no second -RootView or manual AppKit host to steal persistent terminal/transcript surfaces -or override the production toolbar style. The ready log includes the titlebar -height and backing scale for native geometry and resolution verification. -Window preparation waits for a visible key-capable window before sizing and activation; the ready -log records both focus conditions before capture. Vault captures clear the -initial disclosure-button focus without resigning the key window. +`ServerMetricsModel` is application-owned and shares one serial SSH sampler per saved +host across windows, menu bar, and mobile. Rows exist before secrets/networking and +represent connecting, collecting, live, paused, sleeping, or actionable failure. +Workers run detached and enter short, synchronous MainActor-isolated state steps; no +long-lived async method owns observable or host-store state. Credential lookup and remote +I/O stay off actor, and cancellation is rechecked after lookup. Secrets are cleared from +memory on pause/removal/termination. Known-host and credential decisions remain interactive +and scoped to the active monitor. -`ServerMetricsModel` is application-owned, with one serial SSH exec sampler per -monitored saved host. Metrics navigation and the menu-bar popover consume the -same observable rows. Rows are created synchronously from saved selections, -before credential resolution or networking starts, and expose connecting, -connected/collecting, live, paused, sleeping, and actionable failure states. -The menu-bar setting defaults on. Closing the last window keeps the process -alive while that setting or any enabled monitor requires it; explicit Quit -cancels monitoring. NSWorkspace sleep/wake and native window reopening belong -to TFM/Platform, not to the sampling coordinator. +Screenshot launches inject a frozen, in-memory metrics model into the production +WindowGroup with restoration disabled. It never reads saved monitor configuration, +starts samplers, or performs host-key discovery. -Connection preparation snapshots route, identity, public-key and trust metadata -on MainActor. `SSHConnectionPreparation` resolves the Sendable secret store on a -detached worker: Keychain access must never block first paint or state updates. -Cancellation is checked again before connecting, so late credential results -cannot revive a paused monitor. Secrets remain memory-only, are never written -with selections or history, and are cleared when monitoring is paused/removed -or the app terminates. Known-host verification still gates every SSH connection; -interactive trust and authentication are presented in Metrics. +Infrastructure reads bounded Linux procfs and optional hardened `nvidia-smi` XML. +Base metrics survive GPU/process/filesystem failure; invalid deltas remain unknown. +Rates use remote uptime, process identity uses PID plus start time, RSS uses remote +page size, and no process arguments/environment are collected. Five-minute samples +are in memory; ordered monitors, pause, and device selection persist. Filesystem +capacity refreshes only while details are observed. Network defaults prefer physical +interfaces, with explicit selection overriding aggregation. -Linux procfs and optional `nvidia-smi -q -x` are read by Infrastructure with bounded -output and command deadlines. GPU failure does not erase base metrics. Servers without NVIDIA hardware omit -the GPU section entirely. Missing -values and invalid counter deltas remain unknown; remote uptime determines -rates. History is an in-memory five-minute window; ordered host selections, -pause state and device selections persist through the Metrics store. GPU identity uses NVIDIA UUID, and XML external -entities are disabled. There is no WidgetKit extension, App Group, remote agent, -or dependency on AgentKit for monitoring. +`get_metrics` exposes an existing monitor's Sendable snapshot to Agent and the +authenticated MCP bridge. It cannot add hosts, sample, observe details, or access +auth state. It is read-only, parallel, planning-safe, validates host IDs, preserves +section timestamps/age/staleness, uses null for unknown/non-finite values, and limits +processes to 20. Monitoring itself does not depend on AgentKit. -`get_metrics` gives the built-in Agent and the authenticated MCP server the same -read-only view of existing monitoring. `MetricsSnapshotReading` copies Sendable -values on MainActor; tools cannot admit hosts, start sampling, register detail -observers, or access authentication state through this port. A structured monitor -state accompanies the snapshot independently of localized UI text. The tool -validates host IDs against the Agent host listing; known unmonitored targets -return `not_monitored`. It is locally read-only, parallel, and allowed while -planning. MCP exports it by default and preserves the injected reader when tools -are disabled or re-enabled, under the existing server access and audit rules. +Metrics order syncs independently of Vault order; local layout, process sorting, +search, detail navigation, and menu-bar visibility do not. Detail observation alone +enables process/filesystem collection. Authentication opens the shared Connection +Journey while the metrics coordinator retains transport ownership. Temporary trust +is cleared on pause/removal/configuration change; rejection pauses the monitor. +Free access admits at most three cards (paused included), atomically and without +deleting restored cards after downgrade; Pro/trial access is unlimited. -The default output contains core metrics and sample means/peaks over the available -points within the preceding five minutes, with actual bounds and per-metric valid -sample counts. Optional device, GPU, process, and filesystem sections expose only -already collected values; processes retain the existing CPU ordering and are -limited to 20 with explicit counts. Each section carries its own timestamp, age, -availability and stale flag (30 seconds, or 60 for filesystems). Failed sections -may retain older values without presenting them as fresh. Missing/non-finite -numbers are null. JSON uses UTC ISO 8601 timestamps, bytes, bytes per second, and -percent; process CPU remains 100% per core. This projection belongs to Termind's -Agent tools; monitoring itself still has no AgentKit dependency. - -MetricsMenuBarController owns an AppKit NSStatusItem and NSPopover for the -application lifetime. The menu view receives its monitoring model and open -callback explicitly, rather than relying on a window scene's environment. -Preference notifications reconcile item visibility without writing defaults -back, preventing insertion/render feedback loops. - -Metrics overview uses an adaptive grid of compact server cards. Per-window -selection opens a fixed two-column detail page. System panels scroll on the -left and the native process Table scrolls independently on the right; there is -no outer detail-page scroll view or trend tab. CPU breakdown, uptime, load, -free/cached memory and per-device traffic/I/O are displayed in separate panels; -GPU details join the left column only when hardware has been detected. Process counters are collected only while at least one window observes -that host's details; multiple windows still share the same sampler. procfs -process CPU is computed from adjacent process/system tick deltas (100% per -logical core), RSS uses the remote page size, and PID plus start time prevents -reused PIDs from inheriting prior counters. Process reads have separate output -and time limits so errors leave base metrics available. No command arguments -or process environment variables are collected. - -Metrics uses ManagedCardPage/CardTile for the same surfaces, hover, native -selection, marquee, drag ghosts, and reordering contract as Hosts. Its local -monitor order is persisted independently of Vault host order and moving rows -does not reconnect them. The order syncs across devices; adaptive columns, process sorting, -search, detail navigation and menu-bar visibility remain local. Metrics opts into single-click activation; drag gestures retain the shared -reordering behavior and Shift-click extends selection without opening details. -Add Server is a window-toolbar creation action backed by the shared host chooser; -Show in Menu Bar lives only in Settings. The page does not duplicate its title -or creation controls inside the card canvas. - -Both metrics routes use the shared titlebar search field: the overview filters -servers and the detail filters processes, with separate window-owned queries. -The process Table has PID first, user-selectable column sorting, and uniform -row backgrounds. Overview cards fill the available grid width and omit GPU -rows; GPU data belongs to the detail panels. Normal sampling has no redundant -Live badge. Free Memory is a contextual localization key, separate from the -commerce Free label. Native host-chooser row roots explicitly receive AppModel -because an independent NSHostingView does not inherit the parent environment. - -Healthy metrics render no status/timer footer. Detail controls (pause/resume, -retry, device selection and removal) belong beside the toolbar Back button, -not in a duplicate server-name card. Add Server is overview-only. Connection -and authentication notices remain visible when action or waiting is required. - -Overview cards reserve a fixed instrument-body height. Connecting, initial -sampling, paused and attention states replace that body with a centered status -instead of appending a footer or expanding authentication controls. Clicking -an authentication-required card opens ConnectionJourneyView, not metric detail. -Journey owns the familiar identity/passphrase and host-key UI; the metrics -coordinator retains the only transport owner. Temporary host-key acceptance is -scoped to that enabled monitor and cleared on pause/removal/configuration change. -Successful collection completes the Journey before displaying metrics. Rejection -pauses monitoring without accepting a key or creating a terminal workspace. - -Filesystem panels are identified by mount point, with device path, filesystem -type and df capacity. mountinfo major:minor links the mount to per-partition -diskstats; whole-device totals still exclude stacked devices and partitions. -Capacity refreshes every 30 seconds only while details are observed, with its -own bounded command so failures do not discard base samples. Subdirectory bind -mounts and pseudo-filesystems are excluded (root overlays and Btrfs subvolumes -remain representable). Network metadata classifies sysfs-backed hardware versus -virtual interfaces. Hardware interfaces are expanded, virtual ones collapsed; -default aggregate traffic uses hardware when available and otherwise non-loopback -interfaces. Explicit user selections still override aggregation. TCP counters -are host-wide and the traffic ring compares transmitted and received bytes, -not link utilization. - -The Termind menu-bar popover stacks servers vertically with horizontal -separator rules. Each row preserves the overview's horizontal instrument -layout through MetricsInstrumentBody, without CardTile backgrounds. Larger -fleets scroll vertically. Open and Quit are accessible icon buttons -in the upper-right corner. CPU steal is labeled virtualization wait in Chinese; -per-core process CPU semantics remain unchanged without an explanatory banner. - -The menu popover has one continuous surface: header and rows use matching -content insets, separators span the full width, and the list has no nested -scroll background/edge effects. Pointer opening resets input modality and -clears AppKit's automatic first-button focus after presentation. Real keyboard -input keeps the normal accessible focus behavior. - -Free metrics admission is capped at three added cards, including paused cards; -Pro and an active trial allow unlimited additions. The Application model checks -an injected live limit and inserts deduplicated batches atomically, so concurrent -windows and bulk selection cannot overrun the cap. Batch admission happens after -the chooser dismisses, before presenting the existing Pro paywall. Restored or -previously purchased configurations are retained rather than deleted on downgrade; -all retained cards count against future additions. - -## App Store review invitations - -`AppModel` owns one `AppReviewController` for all windows. Its device-local -UserDefaults history records first foreground use, up to three distinct active -dates, requested marketing versions, and the last automatic or manual review -attempt. These records never enter Settings sync and never claim a review was -shown or submitted. A manual Settings link opens the write-review URL directly. - -The controller requires seven elapsed days, three active dates, and five minutes -of foreground workspace time in the current launch. Only leaving a workspace -for a Vault list or the Settings index/App page can arm a three-second invitation. -Native focus, input, sleep and modal state are observed in TFM/Platform; -navigation, input, loss of focus or a blocking presentation cancels the invitation. -Returning to the foreground or dismissing a dialog does not re-arm it. The -SwiftUI presenter calls StoreKit only after the shared controller has persisted -the attempt. Each marketing version gets at most one request, with a 120-day -cooldown also restarted by the manual link. Test and screenshot AppModels default -to disabled controllers, and test-host launches never enable live requests. +The macOS status item/popover is process-owned and receives its model explicitly. +Closing the last window keeps the process alive while the menu item or monitors need +it; Quit cancels monitoring. Native sleep/wake, window reopening, input modality, and +screenshot fixture wiring remain in `TFM/Platform`. ## Settings replication -The iCloud page has one master preference and separate Vault/Metrics/Settings selections. -Vault and Metrics are selected by default; Settings is opt-in. Disabling the master preserves -all selections. Upgrading with sync disabled keeps the master disabled. -Pro access gates all three CloudKit coordinators, whose zones, subscriptions, account -bindings and journals are independent. Metrics uses `TermindMetricsV1`, Settings uses -`TermindAIConfigurationV1`, and the existing `TermindVaultV1` protocol is unchanged. +iCloud has one master switch plus independent Vault, Metrics, and Settings choices. +Vault/Metrics default on, Settings off; disabling the master preserves selections, +and upgrades do not re-enable it. Pro gates three independent coordinators: +`TermindVaultV1`, `TermindMetricsV1`, and the compatibility-named +`TermindAIConfigurationV1`. -Shared `TerminalSettingsModel` and `CommandHistoryModel` live in Application, with -repository protocols there and their concrete SwiftData implementations in Persistence. -Theme palettes and custom-configuration parsing are pure Domain values; AppKit and UIKit -apply them through their native Ghostty adapters. Provider forms share Presentation content -and one atomic provider/endpoint mutation. Saving a provider never selects a conversation model. +V13 Settings entity names/defaults are frozen; V14 only adds +`PersistentTerminalPreferences`; V15 adds the independent Metrics journal/account/ +quarantine. Settings persistence atomically commits data and upload intent. Separate +records cover providers/models, MCP clients, skills, roles, model selection, Web +Search, and terminal preferences/custom text. Remote commits refresh models without +re-entering local mutation. Active Agent runs keep their starting snapshot. -`SettingsSyncPersistence` commits configuration and upload intent together in -SwiftData V14. V13 already exists in installed development stores: its -`PersistentAIConfigurationSync*` names and defaults stay frozen. V14 adds only -`PersistentTerminalPreferences`; the Settings rename preserves the original -CloudKit identifiers as well. Providers (including their models), MCP clients, skills, role -assignments, model selection, Web Search and Terminal preferences have separate -records. Terminal custom configuration text is included. Remote commits refresh -the application models without entering the local mutation path. Web Search and -Terminal preferences are migrated from UserDefaults once, then mirrored there -for existing runtime readers. Active agent runs retain their configuration snapshots. -Conversations, approval mode, local MCP listener settings and other app preferences -are excluded. The listener's enabled state, port and exposed-tool selection remain in -device-local `UserDefaults`; its bearer token uses a dedicated this-device-only Data -Protection Keychain path. Older synchronized listener tokens are copied and verified -locally before their iCloud Keychain copy is removed. Only explicitly selected settings -are projected, never all defaults. +Conversations, approval mode, local MCP listener settings, and unrelated defaults do +not sync. Listener enablement, port, and tool selection remain UserDefaults; its token +uses a this-device-only Data Protection Keychain. Explicit provider/MCP credentials +use `AISecretStore` and migrate only after account verification, verified cloud copy, +and a durable local marker. Reads do not cache secrets; deletion markers prevent late +Keychain delivery from reviving cleared values. Custom MCP headers and terminal text +are ordinary synchronized settings, but explicit credentials never enter CloudKit. -The journal retains tombstones and unresolved references across partial fetches. -Preexisting defaults use baseline versions and yield to cloud records. Built-in -skills use stable IDs; migration preserves customized skills and records deleted -built-ins. Duplicate skill names or MCP namespaces retain their data but only the -lowest UUID is eligible for execution until the conflict is resolved. +Journals retain tombstones and unresolved references across partial fetches. Baseline +defaults yield to cloud records. Built-in skills keep stable IDs; customizations and +deletions survive migration. Duplicate skill names/MCP namespaces retain data, but +only the lowest UUID executes until resolved. Metrics keeps one versioned record per +host; records whose Vault host is absent remain hidden, and samples, prompts, secrets, +and UI preferences never enter its zone. -AI provider and remote MCP client credentials use `AISecretStore`. First Settings sync -checks the iCloud account before migrating local Keychain entries. A verified cloud copy and durable local -storage marker precede local-copy cleanup. Migrated entries remain in iCloud -Keychain when configuration sync pauses. Production reads do not cache secrets; -missing synchronized credentials may arrive later. Pending credential deletion -markers prevent delayed Keychain deliveries from reviving a cleared value. +## Mobile boundaries -Metrics configuration uses one versioned record per host. V15 adds its independent -journal, account state and quarantine, and imports the old desktop and mobile defaults -at baseline versions. Records whose Vault host is absent remain persisted but hidden -until the host appears. Live samples, history, prompts, credentials and platform UI -preferences never enter the Metrics zone. -The built-in MCP listener token never enters this path. Explicit credentials stay out -of CloudKit; custom MCP headers and custom terminal configuration are ordinary user-authored settings. The private-account sync -adapter is the only additional trusted source of skill-library snapshots. +iPhone top-level order is Vault, Workspaces, Assistant, Settings; Metrics is a Vault +section. iPad uses Vault, Metrics, Workspaces, Assistant, Settings. Neither shows +Welcome. Each Workspace owns exactly one SSH or SFTP session; mobile never composes +panes, even with Pro. Assistant is independent. The title menu switches open peers +without connecting/focusing; More owns Rename/Close and cross-opening SSH/SFTP when a +live path exists. Explicit close never retries. -## Mobile presentation and workspace activities +Initial connection failure and recovery stay in Connection Journey. Background-task +expiration closes transports but retains intent; foreground resumes existing +workspaces, while cold restoration connects only when opened. Restoration stores only +descriptor ID, host reference, title, and kind—never commands, paths, credentials, or +actions. Host deletion hides pending records before restoration, runs the shared +outbox saga, and cancels sessions, transfers, forwarding, and local history. -The iPhone has four top-level destinations in order: Vault, Workspaces, Assistant, and Settings. Metrics has its own row/section inside Vault, separate from Keychain, Snippets and Known Hosts. iPad retains a sidebar ordered Vault, Metrics, Workspaces, Assistant, Settings; neither mobile layout has Welcome. Each mobile Workspace -owns one SSH or SFTP session; composition is never enabled by a purchase. Mobile -Assistant is independent and has no workspace sidebar or session-attached sheet. -An open mobile Workspace switches among its peers through the navigation-title -menu; switching alone neither connects nor claims keyboard focus. Workspace -chrome exposes no manual Connect or Disconnect action: an initial failure retries -through its Journey, an established session recovers through the shared reconnect -policy, and the More menu closes the Workspace. A mobile Workspace name is stored -in its restoration descriptor and a rename updates the matching Live Activity -session snapshot. The Workspace list and detail More menu are the two rename -surfaces. Explicit closes never schedule retries. Background-task expiration closes -transports without discarding the connection intent or Journey; the next foreground activation immediately resumes -those Workspaces. Cold-launch restoration remains lazy and starts only the Workspace -the user opens. Connection, authentication, host-key and -reconnection failures remain inside Journey and never fall through to a second -alert after Journey closes. SFTP browsing and file-operation failures use a -separate operation-error channel and retain the native alert used on macOS. The terminal -key strip remains visible while the software keyboard is dismissed, uses a borderless -44-point height, and keeps its keyboard toggle outside the horizontally scrolling function -keys. SSH and SFTP Workspace detail hides the iPhone root tab bar; returning to the -Workspace list restores the root navigation. +One serialized Live Activity represents all Workspaces with counts and up to six +distribution icons. Validated links open a Workspace or list and carry no secrets or +commands. It starts only in foreground, ends with the last Workspace, and cannot keep +SSH or Agent runs alive. Immutable activity snapshots are separate from observable +Workspace/Vault state; a user-dismissed activity is not resurrected by ordinary +foreground updates. -Mobile SFTP renders its reusable, diffed file rows through ListViewKit. The Workspace -session owns the current path, editable path text, current-directory filename filter, -and backward/forward directory stacks; reloads and post-mutation refreshes never add -history. Its navigation-bar chevrons traverse directory history, while the system -left-edge interactive pop remains the separate way to return to Workspaces. The path -bar mirrors desktop with breadcrumbs that become an absolute-path editor on activation. +Mobile Settings exposes App, Terminal, SFTP, AI, and Sync. The macOS window-title +preference stays synchronized but has no mobile control; there is no Live Activities +settings page. AI manages Models, Skills, and MCP with explicit-save drafts. Network +tests/discovery use ephemeral snapshots, cancel on editor/config changes, and cannot +repopulate discarded drafts. Secrets remain in memory until atomic save and roll back +on metadata failure. Mobile does not start the local MCP listener. -Every mobile SSH-backed connection initializes its Journey route before publishing -connecting or failed state. Route rendering accepts zero, one, or many endpoints, and -generation-scoped progress and failure callbacks from a retired transport cannot mutate -the Journey or retry state owned by its replacement. -Reconnect timers sleep in detached work, then enter an explicit MainActor-isolated -method before invoking the session retry action; inherited Task isolation is not relied on. +Mobile Agent runs belong to the app, not the visible page. Conversations retain +independent drafts/runs; archiving persists metadata before navigation and becomes +read-only until restored. Attachment parsing and speech are injected services; speech +appends without sending and cancels on interruption, conversation change, or +background. Mobile shares desktop-independent tool registration and permission/ +secret interaction, but finite background expiration cancels runs, drains events, +closes Agent transports, and never replays or auto-approves work on foreground. -Shared form content is mounted in the desktop work column and in mobile SwiftUI -sheets. Short mobile forms offer medium/large detents; complex forms use large. -Dirty drafts prevent interactive dismissal and survive size changes. - -The iOS widget extension uses one ActivityKit activity for all workspaces, not one -notification or activity per workspace. Its bounded state contains counts and up to -six host system icons, resolved through the same distribution catalog as Vault, -with the focused session first and a list link for overflow. An unrecognized -system uses the server icon; SSH shell-integration updates refresh the artwork. -Each icon opens `termind://workspace/`; the compact island opens the focused -session, while `termind://workspaces` opens the list. Links are validated and carry -no commands or credentials. A stale activity displays an instruction to check the -connections instead of asserting connectivity. Closing the last workspace ends it. -Activity starts, updates and ends are serialized, and starts require the foreground. - -Live Activities do not keep SSH alive. The app requests finite background execution, -stops sampling and Assistant runs, and closes transports when that time expires. -Opening a session link restores its page and reconnects if necessary using normal -credential and trust gates; commands and tool actions are never replayed. Restoration -persists only descriptor IDs, host references, titles and session kinds. -Workspace property observers publish immutable Activity session values. The app keeps -their ordered snapshot separately from live Workspace and Vault models, so Activity -updates never re-read observable session or Host state. ActivityKit writes remain serialized. - -Mobile transfer queues use the same coordinator as desktop and own dedicated connection -configurations. Closing a browser does not cancel those transfers. Deleting a host or -expiration of background execution cancels its work; no transfer is silently replayed. -Downloads are staged in a unique app-temporary directory and exported through the system -share sheet. Remote names are validated before becoming local path components. - -The bottom group in a mobile Workspace's More menu opens the same live remote directory -in a new Workspace of the opposite kind when available, then offers Rename and Close -without separators between those actions. SSH offers SFTP only after Shell Integration -has reported a working directory; SFTP offers SSH only while its browser is connected. -Unavailable actions are omitted. The initial SFTP path and the SSH shell's generated, -history-excluded `cd` command survive authentication/trust retries but remain live-only; -neither enters restoration metadata nor replays on reconnect. - -Mobile host deletion records the existing deletion saga and starts the shared outbox -runner. Restored pending deletions are hidden before workspace restoration. The runner's -start/stop generation prevents a cancelled background loop from clearing a replacement -foreground loop. New port forwarding rules never start merely because they were saved. -Rules explicitly marked Enabled keep that device-local intent through background expiration -and resume when the app next becomes active. - -Mobile Snippets retain the shared Vault records, nested packages, ordering and target -host references. A card opens a read-only detail; execution is always an explicit action. -On iOS, each card's lower-right timestamp is the Snippet record's Vault journal -`modifiedAt`: the first save is its added time and later persisted changes replace it. -Pre-journal baseline records use a stable, device-local migration timestamp for display -without rewriting the record or creating a sync conflict. -Each resolved target opens an independent SSH Workspace, including free multi-host runs, -because mobile never composes panes. The initial command is live Workspace state only: it -is submitted once after the first successful authentication/trust flow, then consumed. It -is absent from restoration descriptors, so reconnect, foreground restoration and Activity -links can never replay it. Temporary execution targets do not mutate the saved Snippet. - -Mobile Settings exposes full terminal themes, font controls, custom text, shell integration, -Vault/Metrics/Settings sync, providers, model discovery and role assignments. The macOS window-title -preference remains in synchronized records but has no mobile control. Search belongs to each -workspace and uses Ghostty's search actions; history insertion uses the paste path without -appending Enter. Only authenticated integration events produce history rows; interrupted -commands retain unknown completion. App-wide history is bounded to 1,000 rows per host. - -Mobile sync runs while a scene is active and Pro access permits it. Backgrounding pauses -both engines, retaining upload journals; foregrounding fetches again. Entitlement changes -reconcile the same gate. Remote host deletion closes its workspaces, cancels transfers and -forwardings, and clears local command history. Modified workspaces are disconnected rather -than automatically replayed; locally Enabled forwarding rules restart with the accepted configuration. -Mobile AI/Terminal edits preserve all other Settings record kinds, -including Skills and MCP configurations. Skills use the shared Application library and the synchronized persistence adapter on both platforms. Active chat requests retain -the provider/model/credential captured at start. Assistant now registers the same tools as the desktop independent Assistant through the shared registry. - -Mobile navigation and Metrics reuse (2026-09-11): - -- Transfers belongs to Workspaces. Host cards connect to SSH on a single activation and expose SFTP/edit/delete through a visible More menu and a long-press context menu. SSH folder import is desktop-only; mobile retains private-key and certificate file import. -- Port forwarding rules belong to the host draft and commit with it. Enabled intent is persisted immediately on - the local device, independently of the draft: saved rules run with their last saved Host and rule configuration, - while a newly enabled rule waits for its first successful Host save. The mobile host editor has no bulk controls. -- Keychain lists Keys before Identities. Identity notes remain preserved data without an editor field. Generation defaults match macOS: iCloud, Ed25519, ECDSA P-256, RSA 3072, AES-256 and 100 rounds; encrypted generation requires a matching confirmation. The shared bounded/scoped key-file reader accepts explicit private-key and certificate files. -- ServerMetricsModel, ServerMetric, MetricsClock and ConnectionJourney live in Application. Their samplers/discovery functions remain injected. Mobile delegates its collector to this shared model, including authentication, temporary trust, process/filesystem sampling and detail observer lifetimes. -- Metrics instruments/system panels, Journey route/checkpoint animations, and SSH decision forms live in Presentation. AppKit's keyboard monitor stays in TFM and is injected through ConnectionFormKeyboardAdapting; Presentation has no native UI imports. -- Metrics details show status and processes together at content widths of at least 700 points; narrower widths show a two-icon segmented switch. Width, rather than orientation or device model, determines the layout. - -Mobile Settings mirrors the desktop category hierarchy with App, Terminal, SFTP, AI, then Sync. -App owns appearance, language selection (effective on relaunch), runtime versions and support -links. AppLanguage and AppLocalization are shared Foundation-only Application values; native -relaunch remains macOS-owned. There is no Live Activities settings section. Opening a workspace -starts the activity, and the first foreground activation restores it for retained workspaces; -later foreground updates do not resurrect an activity dismissed by the user. OS authorization -still controls availability. - -The mobile navigation shell uses device idiom: iPhone keeps its four tabs even in landscape, -while iPad uses NavigationSplitView's own width adaptation. Metrics' two-column breakpoint uses -the actual detail content width. The iPad sidebar has no application-name title. An SSH terminal -surface remains attached underneath the Journey, -so early output and viewport changes are retained while credentials or trust are presented. - -### Mobile AI settings (2026-09-11) - -Provider connection, credential and models share one explicit-save editor. Fetch and -model probes use an ephemeral snapshot of that draft, never a provisional database -record or temporary Keychain entry. Fetch chooses models in a separate sheet; model -parameters expand in the editor. Filter is leading-aligned; Fetch and Add sit below -the model list and both open modal sheets, initially medium-height on compact text -sizes. The fetch sheet uses a leading search field and tappable rows with trailing -checkmarks rather than switches. Candidates show their display name above the ID, -falling back to the ID only when no display name exists; manual Add opens a focused Model ID field. Existing models have no multiselection control; Test and Delete are -row context-menu actions. The fetch chooser alone selects models for import. A provider save commits its endpoint and model list -and clears references to removed models in one configuration transaction. Unedited -fields follow remote updates; competing edits fail without discarding the local draft. - -Skills and remote MCP have mobile management UI, including scoped Markdown import/export, -HTTP discovery and tool access policies. The portable skill library lives in Application, -file import in Infrastructure and role form in Presentation. Both apps retain native -file pickers and editors. MobileAppModel projects remote skill snapshots without writing -them back. The sync persistence owns built-in seeding; constructing the mobile library -does not seed a second time. Mobile skill and MCP editors save explicitly. Secrets -stay in editor/request memory until a save; metadata failure rolls secret changes back. -Discovery and tests are cancelled when the editor closes or the relevant configuration -changes; late responses cannot repopulate a discarded draft. Model probes run sequentially. -MCP configuration respects the existing remoteMCP entitlement. This UI does not register -Agent tools, supply an approval presenter or start a local MCP listener on iOS. - -Provider, Skill and MCP enable switches live inside their mobile editors and follow -the explicit-save draft. Mobile Provider, Skill and MCP lists use ordinary rows, never cards or grids; rows -contain summaries and editor links only. -Desktop keeps its existing list switches and also exposes enable controls in all -three detail editors; both controls write through the same feature model. Desktop -Enabled controls follow Name (after Title and stable Name for Skills). The shared -provider form uses Name then Enabled on both platforms; mobile Skill and MCP editors -retain their leading enable row. Desktop and mobile forwarding editors place Enabled -and runtime status above the shared, flat Port Forwarding form. Their contextual titles -are New Forwarding or Edit Forwarding, and active forwarding locks the configuration fields. -Terminal custom configuration has no Name field and keeps its enable control in the toolbar. - -Mobile local uploads and desktop local uploads use one batch planner in Remote Infrastructure. File picking and -URL drag-and-drop feed the same recursive enumeration, remote-conflict preflight, and replace/skip/keep-both -resolution. SSH terminal drops target the Shell Integration working directory and retain the existing Pro gate; -SFTP drops and picker uploads target the visible browser directory and remain free. Security-scoped roots are -retained by queued transfers, and temporary SSH-side SFTP clients are released when preparation finishes or is -cancelled. - -Mobile modal sizing is centralized in MobileSheetPresentation. iPad uses the system -form presentation without phone detents, so dialogs center in the presenting window -and retain system keyboard avoidance. iPhone retains compact medium/large trays -and large editing sheets; accessibility text sizes use large trays. The same policy -covers all editors, AI pickers, Pro, history, transfer and Metrics selection sheets. -Mobile editor fields align by structure: editable values paired with a leading label -align trailing, while standalone fields align leading. Save remains the trailingmost -editor action. Skill Export/Delete and MCP Connect/Delete live in the adjacent More -menu; Provider Fetch/Add, MCP Add Header, Host Add Forwarding, key file selection, -and Terminal configuration editing remain beside the content they affect. SFTP keeps -Transfers visible and groups parent navigation, upload, and folder creation in More. - -Mobile AI Settings is a native navigation index with exactly three destinations: -Models, Skills and MCP. Models owns provider management and Model Roles. Each -destination owns its list and navigation-bar actions: Models and MCP add from the -primary plus button; Skills exposes Import and Add in the primary toolbar and keeps -Reset in its More menu. Add/import/reset controls never appear as list rows. The -MCP destination manages servers with native List navigation: existing rows and the -primary Add action push the explicit-save server editor in the same stack rather than -presenting a modal sheet. Custom headers remain server-draft values, use native rows -inside that editor, and commit only when the server is saved. -Assistant MCP shortcut presents the same complete MCP page and adds only its Done -action at the presentation boundary. Index rows use direct destination links, like -the parent Settings page, so they work inside both the iPhone stack and the iPad -split-view detail stack without a nested value-route registration. An empty Models -page uses the same native ContentUnavailableView pattern as No Hosts, titled No -Providers with a short instruction to add one; the primary plus remains available. - -The Chat role picker, like the Assistant model picker, lists only concrete models. -When Chat has no explicit assignment, Settings displays the model resolved by the -existing fallback chain rather than an Automatic option. Choosing a different model -creates the explicit assignment. Command Generator and Security Review retain Same -as Chat because those unset roles intentionally follow Chat. - -A new mobile Provider has no credential to preserve. Its editor starts in replacement -mode and presents a required API Key field directly; Save remains disabled until the -key is entered. Keep, Replace and Clear are shown only when editing a Provider that -already has a credential reference. An existing keyless Provider instead shows an -optional direct key field and can save unrelated edits without inventing a credential. -The plaintext remains draft-only until Save. - -### Desktop configuration detail controls (2026-09-11) - -Terminal custom-configuration editors expose the same immediate-persistence enable -preference as the parent settings page. Both light and dark editors remain reachable -while disabled; toggling never clears or replaces their independently autosaved text. - -Desktop forwarding lists and rule details share immediate, device-local Enabled intent. -An existing rule can be enabled while its Host or rule draft differs, and the runtime -uses the last saved configuration until the Host is saved. A new rule can be enabled -before its first save; it remains Waiting for Save and starts only after the Host -transaction succeeds. Active runtime phases lock configuration edits until disabled, -but an enabled rule that has not started remains editable. Cancelling an existing rule -editor does not undo enable/disable actions; cancelling an unsaved rule or its Host -editor removes that rule's pending intent. Enable All and Disable All follow the same -policy. - -### Mobile card presentation and color tags (2026-09-11) - -TFI owns the touch card canvas and its menus. iPhone uses one column in either -orientation; iPad derives column count from actual content width, with a 320pt -minimum. Accessibility text sizes use one column. Primary actions and More menus -are sibling controls, never nested buttons. Card views do not own connections, -collectors or drafts. Settings role forms and native editors remain native forms. -Vault's Metrics, Keychain, Snippets and Known Hosts navigation entries remain native -List rows. Workspaces, including its Transfers entry, and the Settings index remain -native List/Form pages; card presentation begins only after navigating into content -whose records benefit from visual recognition. -Glass surfaces use the iOS 26 renderer, with opaque semantic surfaces on older -systems or when transparency reduction/increased contrast requires it. -Standard mobile cards reserve a 104pt two-row minimum height. Content-rich cards -and accessibility text may grow past that floor, but Host tags remain on one line -and show at most the first two, matching the desktop card contract. - -TagColor's SwiftUI mapping and ColorTagPicker live in Presentation, without native -platform imports. Desktop explicitly selects compact swatches; mobile selects -wrapping 44pt touch targets. Host, identity and key colors remain explicit-save -draft fields, persisted through existing Vault operations. Tag tint affects item -artwork, never the independent trust or runtime status colors. MetricsInstrumentBody -defaults to the desktop horizontal instruments; mobile opts into adaptive content -with Dynamic Type and no fixed content height. +ListViewKit is an app-owned implementation detail: macOS uses native bridges for the +streaming transcript, large SFTP lists, conversation library, and tool sidebars; iOS +uses one UIKit bridge for transcript and large SFTP lists. Bounded navigation and +archive pages remain SwiftUI `List`. Shared frameworks never import ListViewKit, and +architecture tests own its allowlist. -## Shared Assistant and native mobile composition (2026-09-11) +### Assistant transcript presentation parity (2026-09-12) -Application owns ConversationModel, AssistantConversationStore/LibraryModel, the execution controller, durable conversation operations, Termind's runtime policy, SSH interaction values, and the speech coordinator. RemoteInfrastructure owns SSH/SFTP Agent definitions, connection pools and the concrete registry factory; Application receives that factory rather than importing transport implementations. Infrastructure owns bounded attachment parsing and Apple Speech recognition; a typed attachment loader is injected into each conversation. The versioned AgentKit dependency and persisted schemas remain unchanged. +TermindPresentation owns tool summaries, batch status semantics, bounded result previews, timestamp formatting, +Markdown sanitization/soft-break policy and the shared disclosure/deadline state. Hosts inject a resolved +AgentToolDetailPresenter from TermindRemoteInfrastructure; Presentation never imports the registry's transport +module. Tool protocol results, AgentKit versions and persisted schemas are unchanged. -Mobile runs belong to the app, not the selected page. Switching conversations preserves their individual drafts and runs. Archive commits metadata before updating navigation, clears pinning, and exposes read-only history until explicitly restored. No conversation or permission selection enters Settings sync. +TFM retains native AppKit rows, Markdown caches and message editing state. TFI retains SwiftUI content inside +its UIKit ListViewKit bridge. A row's presentation revision can change without its data changing: the mobile +bridge reconfigures that row explicitly, invalidates only its height, and retains its UIHostingConfiguration +content view. Display/measurement purpose, Reduce Motion and scene activity cross that hosting boundary. +Only displayed active content starts timers or scanning; the shared scan parameters and three-second reasoning +deadlines cannot restart merely because a row is recycled. Work completion follows the shared default only +after the whole row leaves the viewport, unless the reader has already chosen its disclosure state. -The mobile composer is a SwiftUI vertical TextField, with camera, photos, files and speech in its leading menu. UIKit is confined to camera/preview/audio adapters and system integration; no UIKit text editor replaces the composer. Speech appends text without sending, owns a conversation token, and cancels on interruption, conversation change or backgrounding. - -Agent runs may consume the finite background execution time already requested by the mobile shell. Expiration invalidates pending starts, drains cancellation events, closes Agent transports and prevents new starts until cleanup ends. Returning to the foreground never replays commands or approves waiting actions. Platform suspension still bounds execution; Live Activities do not keep Agent runs alive. - -### ListViewKit boundary (2026-09-12) - -ListViewKit is a platform-independent implementation detail for native lists whose interaction or update behavior -benefits from explicit row reuse and viewport ownership. TFM uses it for the streaming Assistant transcript, large -SFTP directories, the Assistant conversation library, and window tool sidebars. TFI uses one UIKit bridge for the -streaming Assistant transcript and large SFTP directories. Bounded Assistant conversation and Archive management -pages use native SwiftUI `List` for system swipe actions and navigation behavior. Each app target owns its native -bridge; shared frameworks do not import ListViewKit. An architecture test owns the production import and -bridge-consumer allowlists so the dependency cannot spread without an explicit boundary change. +The direct approval callback stages a tool card idempotently before accepting a response. Queued tool-proposed +and approval-requested notifications update that identity without erasing an already recorded denial. This keeps +UI state independent of whether the event channel or the direct interaction reaches MainActor first. diff --git a/Memory/MOBILE_ROLLOUT.md b/Memory/MOBILE_ROLLOUT.md index bf7015b6..78240208 100644 --- a/Memory/MOBILE_ROLLOUT.md +++ b/Memory/MOBILE_ROLLOUT.md @@ -1,217 +1,75 @@ # Mobile rollout status -## Implemented +This file records the current mobile baseline, not the implementation chronology. +Do not claim complete desktop feature parity until the outstanding device and +service verification below is finished. -- Shared Vault and AI configuration models and persistence, SSH/SFTP transports, - Keychain adapters, StoreKit access, metrics sampling, and a SwiftUI presentation target. -- Four iPhone navigation destinations: Vault, Workspaces, Assistant, Settings. Metrics is a separate Vault entry; iPad retains Vault, Metrics, Workspaces, Assistant, Settings in its sidebar. No mobile workspace composition or workspace-attached Assistant. -- Mobile host, identity, group, snippet and provider forms use sheets; desktop host - connection fields reuse the same SwiftUI content. -- Independent SSH/SFTP workspace descriptors, reconnect entry points, terminal rendering, - system keyboard and a SwiftUI accessory bar using Ghostty's sticky modifiers. -- One ActivityKit activity with up to six host system icons and an overflow list link. - Each icon deep-links to its workspace and reconnects through credential/trust checks. - Compact/minimal presentations open the focused workspace. The distribution artwork - is resolved through the same catalog used by the host list and refreshed by shell integration. -- Workspace restoration contains metadata only. Live Activities do not grant execution - time; the app uses finite background execution and disconnects at expiration. -- SFTP browsing, recursive multi-item picker/drag upload with desktop-equivalent conflict handling, download, - rename, permission editing, directory creation and deletion; SSH terminals accept Pro-gated file/folder drops - into the Shell Integration working directory. Upload preparation and queue notices stay in the current workspace. - Mobile SSH also exposes the shared Command Generator from its More menu, reads the live shell draft, and inserts - the validated command without executing it. The app-wide shared transfer queue provides cancellation and - current-launch history; metrics cards; independent streaming chat with persisted history; provider configuration - and Pro purchase presentation. +## Current capability -- Key generation (including supported Secure Enclave devices), encrypted key import, - metadata/certificate editing, and key/identity deletion. File parsing and generation - run off MainActor, and secrets stay in the shared secret store. -- SSH folder/known_hosts bulk import remains desktop-only. The mobile import screen and entry were removed at user request; individual private-key and certificate file imports remain supported. -- Port forwarding management uses the shared runtime and transactional rule updates. -- Host deletion uses the shared outbox runner, including interrupted-job recovery on startup. -- iOS Keychain sharing entitlement and localized Face ID usage description are configured. +- Shared Vault, Settings, CloudKit journals, StoreKit access, Keychain adapters, + SSH/SFTP transports, Metrics, presentation components, and deletion outbox. +- iPhone uses Vault, Workspaces, Assistant, Settings; Metrics is inside Vault. + iPad uses Vault, Metrics, Workspaces, Assistant, Settings. Mobile has no Welcome, + pane composition, workspace-attached Assistant, or bulk SSH-folder/known_hosts + import; individual key/certificate imports remain supported. +- One SSH or SFTP session per Workspace, lazy metadata-only restoration, shared + Connection Journey/recovery, terminal rendering, system keyboard/accessory bar, + command history/search, Command Generator, and cross-opening SSH/SFTP from a live + directory. Generated or historical commands paste without automatic execution. +- SFTP list/mutation, recursive picker/drop upload, conflict handling, download, + permissions, transfers, and Pro-gated SSH drops share desktop policy. Transfers + use dedicated connections and survive browser closure. +- Key generation/import/edit/delete, including supported Secure Enclave devices; + port-forward drafts and device-local enabled intent; transactional Host deletion + and recovery of interrupted cleanup. +- Shared Metrics sampler/Journey/panels. Detail uses two columns at 700 points or a + compact switch below that width. Monitor secrets and temporary trust are not + persisted. +- App, Terminal, SFTP, AI, and Sync Settings, including themes/fonts/custom Ghostty + text, providers/models/roles, Skills, remote MCP, Web Search, and synchronized + configuration. The local MCP listener and macOS window-title control remain absent. +- Independent persisted Assistant conversations with the shared desktop-independent + tool registry, approvals/user input/secrets, attachments, and speech input. Runs + survive page switches but remain bounded by iOS background execution. +- One serialized Live Activity summarizes up to six Workspaces and deep-links + through validated metadata-only URLs. It never keeps SSH or Agent work alive. +- TFI follows the same layered source vocabulary as TFM. Shared Application owns + connection recovery, terminal drafts/find, SFTP state ports, Assistant execution, + and speech coordination; native surfaces/adapters remain in each app. -## Remaining from the full plan +## Important behavior -The full mobile port is not complete. Do not describe the current implementation as feature parity. +- Background expiration closes transports, transfers, samplers, and Agent runs but + retains Workspace intent; foreground recovery uses normal credentials/trust and + never replays commands, tool actions, uploads, or approvals. +- Shell-integration and transport-exit callbacks use one generation-scoped ordered + MainActor stream. Bash ignores Termind's internal `__termind_*` draft helpers; + authenticated installation/completion are prompt boundaries and duplicate prompt + markers are harmless. +- Sync operates only while a scene is active and Pro permits it. Remote Host deletion + closes its sessions, transfers, forwarding, and local history. Active chats retain + their starting provider/model/credential snapshot. +- iPhone remains tab-based in landscape; iPad uses the split view's width adaptation. + Editors use explicit-save drafts and preserve unrelated synchronized record kinds. -- Port complete shared Assistant tool registration, approval/user-input/secret presentation, - SSH/SFTP agent tools, Skills, MCP/Web Search settings and speech input. - The current mobile runtime intentionally registers no tools. -- Continue broader desktop SwiftUI view reuse and complete Assistant settings/tool execution beyond provider/model configuration. -- Test live SSH/SFTP, ActivityKit rendering and taps, external keyboards, background/lock - behavior, CloudKit and StoreKit on signed physical devices. Widget icon routing and - bounded payload behavior have automated coverage; real-device behavior is unverified. +## Latest recorded verification -## Verification so far +- Complete iOS Simulator action: 257 tests passed. +- Both app builds, 58 focused macOS architecture/theme/Journey/terminal-find tests, + shared policy suites, strict Swift formatting, Simplified Chinese localization, + and `git diff --check` passed. +- A connected iPad reproduced and then verified the Bash Command Generator fix on a + live host. Signed physical-iPad target compilation also passed. +- The full macOS action still has the known Keychain-environment, native Metrics + focus, and Agent timing failures documented in `REFACTOR.md`; focused Agent + reruns passed. Do not report the full macOS suite as green. -- iOS simulator build and tests pass: 15 mobile tests and 20 shared framework tests. -- macOS focused tests pass: 42 tests covering architecture, screenshot fixtures, - command history and application language. -- Full macOS run executed 1,122 tests. Agent deadline/approval failures passed on a - focused rerun. DataProtectionKeychainTests still returned OSStatus -25308 and - MetricsInteractionTests still failed native popover focus/dismissal expectations. - These failures remain unresolved; do not report the full suite as passing. -- Simplified Chinese localization checks pass. New UI text has translations. -- iPad sidebar and sheet presentation inspected in Simulator. Inspection caught missing - persistent field labels; the shared form now uses LabeledContent for those fields. +## Still required -## Continuation verification (2026-09-11) - -- 51 macOS tests passed for SSH import, forwarding, repository/outbox behavior and architecture. -- 49 macOS SFTP feature/session/history tests passed after sharing the transfer coordinator - and Unix permission controls. The permission-grid refactor in REFACTOR.md is verified. -- Mobile tests cover encrypted key generation/import, rejection of a wrong passphrase, - metadata edits preserving credential references, explicit import usernames, transactional - forwarding changes, and completion of durable host-deletion cleanup. - -- The continuation's full macOS run again executed 1,122 tests and reported 14 issues - in the previously failing deadline/approval, Keychain (-25308), and native popover suites. - The full suite is still not green; the focused feature suites above passed. - -## Terminal, sync and AI configuration continuation — 2026-09-11 - -Implemented shared CloudKit coordinators, Settings transaction/journal, terminal preferences, -command history and provider connection fields. Mobile uses the synced configuration store, -preserving Skills/MCP records and unresolved references. Defaults match desktop: Pro-gated -master/Vault enabled, Settings opt-in. Both categories pause in background and resume with -foreground/entitlement reconciliation. APNs registration and CloudKit signing are configured; -signing files are now `TFI/TFI.entitlements` and `TFM/TFM.entitlements`. - -Mobile Terminal has light/dark theme catalogs, font controls, custom Ghostty text, integration, -search and per-host history. The remote-title preference is deliberately absent from its UI. -Search queries/surfaces survive navigation; historical commands paste without an added newline. -Provider forms support empty model lists, endpoint derivation and explicit keep/replace/clear -key intent; failed secret/configuration writes keep drafts and attempt credential rollback. -Model discovery is cancellable, adds selected candidates only, and ignores stale responses. -Roles and chat pickers use shared fallback rules. Runtime tools remain intentionally absent. - -Verification in this continuation: - -- iOS simulator builds and 25 mobile plus 26 shared tests passed, including 16 new regression tests. -- Full macOS run executed 1,123 tests and reported 9 issues in existing Agent approval, - user-input deadline and cancellation tests; all 26 shared tests passed separately. -- Focused rerun still reproduced two existing AgentApprovalQueueTests failures. A catalog - check also caught newly auto-extracted, previously untranslated mobile strings; these - translations have now been supplied; 68 focused macOS feature/language tests passed afterward. -- iPhone Terminal preferences and iPad English/Chinese, light/dark settings and provider forms inspected. iPad rotation preserved drafts and cancel required discard confirmation. -- Connected-device inventory reports the paired iPhone and iPad unavailable. Real SSH, - external-keyboard hardware, background/lock and signed Mac/iOS CloudKit delivery remain - unverified. Production schema deployment was not attempted. AgentKit and Ghostty pins unchanged. - -### Follow-up navigation and desktop parity - -Vault replaces Welcome on both mobile layouts. iPhone's Metrics tab is replaced by Workspaces; -Metrics gets its own Vault section above the other three library links. Transfers moves into -Workspaces. iPad places Metrics immediately below Vault in its sidebar. Host rows connect SSH -on tap and use long-press menus. macOS Navigator now labels its workspace group “Workspaces”. - -Metrics reuses desktop instruments, system panels and the complete shared monitor/Journey -state, with shared credential and host-key forms. At >=700 points, details display status and -processes side by side; narrow layouts use a two-icon switch. Keychain order/defaults and -certificate import align with macOS. Identity notes have no mobile editor. Port forwarding -is staged inside the host editor, with explicit runtime actions only for saved rules. Mobile -SSH folder import was removed, while individual key/certificate import remains. - -Settings now has App as its own page, and Sync below AI. The redundant Live Activities -settings section was removed; activity startup/restoration is automatic for workspaces and -continues to respect OS authorization. Shared app-language settings take effect on relaunch. -Phone tabs remain available in landscape; iPad uses a collapsible sidebar. - -Follow-up verification: -- iOS: 29 mobile tests and 28 shared tests passed, including certificate-file validation, - default key generation options, navigation/breakpoint rules and shared Metrics credential/ - temporary-trust continuation without persisting secrets or trust. -- macOS: 127 targeted tests across 14 suites passed, covering architecture, language, sync, - terminal history/themes, Journey, Metrics lifecycle/presentation/interaction, snapshots, - key-file import and SSH certificates. -- A full 1,124-test macOS run reported existing Agent timing/native-popover issues plus a - brittle new source-format assertion; that assertion and the shared fake-process fixture - were corrected, and the relevant targeted suites then passed. Final full-run status follows. - - -Final macOS regression (2026-09-11): all 1,124 app tests were executed. Six issues remain -in the preexisting AgentApprovalQueueTests/AgentUserInputTests timing cases; architecture, -Metrics interaction/presentation, connection forms, localization and the other suites passed. -All 28 shared tests passed. These results do not establish a green full macOS suite. -Logs: `/tmp/termind-mac-port-final.log`, `/tmp/termind-mac-nav-focused.log`. - -Final visual review on iPhone confirmed the separate Metrics section in Vault, four bottom -tabs, absence of bulk SSH import, forwarding nested in the host editor, leading-aligned -forwarding Name with trailing address/port fields, and Settings ordered App/Terminal/AI/Sync. -The standalone App page shows appearance, language, versions and support; no Live Activities -settings are exposed. Earlier iPad bilingual/theme/rotation checks remain recorded above. - -Final iOS verification: 29 mobile and 28 shared tests passed after the complete navigation, -App/Sync settings, credential parity and persistent Journey-overlay changes. Log: -`/tmp/termind-ios-port-complete.log`. Strict Swift formatting, `git diff --check`, and -`Scripts/check-zh-localization.sh` passed. No dependency pins or cloud schemas were changed. - -## Command Generator and local upload parity — 2026-09-11 - -Mobile SSH now owns the same guarded draft-read/generate/bracketed-paste lifecycle as desktop and exposes it from -the workspace More menu. Local upload selection and URL drops share the desktop batch planner across SSH and SFTP, -including recursive folders, conflict preflight, and replace/skip/keep-both resolution. - -The iOS simulator run passed all 221 tests. Command Generator, Broadcast Input, and SFTP focused macOS tests passed. -The full macOS run executed 1,176 tests and reported three unrelated failures: two known Agent timing cases passed on -immediate focused rerun, while the existing ArchitectureBoundary source assertion still expects the App icon `app` -despite the current uncommitted mobile settings source using `app.badge`. Both app builds, strict Swift formatting, -Simplified Chinese localization checks, and `git diff --check` passed. - -## Ordered mobile Shell Integration delivery — 2026-09-11 - -The original mobile Command Generator tests invoked `receiveShellEvent` directly and therefore did not verify the -production callback hop from the SSH worker to MainActor. Mobile SSH now places Shell Integration and transport-exit -callbacks in one session-scoped ordered stream. Closing, reconnecting, or expiring a session terminates its consumer, -so a late callback cannot reorder prompt readiness or mutate a replacement session. - -Six focused mobile Command Generator tests passed, including background callback delivery, command completion, -disabled-integration transport configuration, and stale-event isolation across an exit and replacement connection. -The 43 focused macOS Shell Integration, Command History, and Broadcast Input tests also passed. The full iOS run -passed 232 of 233 tests; the remaining failure is in the concurrently edited MobileCard key-color test and is -unrelated to SSH or Command Generator. Simulator, macOS, and signed physical-iPad target builds passed. Live SSH -verification on the device remains pending. - -Physical-device diagnosis then confirmed that the affected bash host had Shell Integration enabled, a detected -remote distribution, and authenticated command history. After the first readiness correction, tapping Generate -Command produced an unfinished History row containing the draft itself. This identified the actual cancellation: -bash's DEBUG fallback treated the internal draft-reading `bind -x` helper as a user command, emitted command-start, -and immediately marked the prompt busy. The fallback now passes `BASH_COMMAND` into the hook and ignores Termind's -own `__termind_*` helpers. Mobile also treats authenticated installation and command completion as prompt boundaries; -duplicate prompt markers are idempotent. Seventeen shared bootstrap tests, including a real bash runtime regression, -and all six focused mobile Command Generator tests passed. The root-cause build was installed and launched on the -connected iPad at 22:30. The affected live bash host then completed Command Generator successfully on that iPad. - -## Layered mobile source and canonical framework names — 2026-09-12 - -TFI now uses the same App/Application/Design/DesignSystem/Features/Infrastructure/Platform/UI layer vocabulary as -TFM. Workspace transport construction moved from the feature model into Infrastructure, mobile layout constants -and interface idiom are centralized, and navigation metadata lives in Shell. The shared connection controller now -owns recovery state for desktop SSH, desktop SFTP, and mobile Workspaces. - -The desktop compatibility forwarding layer was removed. Both apps import canonical framework types directly; -temporary `Shared` names were restored to responsibility-specific names. macOS and iOS simulator builds, -ArchitectureBoundaryTests, and the complete iOS test action pass. - -Terminal draft reads and Command Generator execution now use the same Application controllers on both platforms, -including cancellation, timeouts, stale-context rejection and bracketed-paste insertion. Focused mobile Command -Generator and desktop Broadcast Input/Terminal Find tests pass after the extraction. - -## Cross-platform session and presentation follow-up — 2026-09-12 - -Mobile SFTP directory state now depends on the Application `RemoteFileSystem` port. `RemoteFile` is a Domain value; -the libssh2 client refines the port in Remote Infrastructure, while symbols and localized kind labels live in -Presentation. Path, listing, mutation, stale-request, and operation-error state moved from `MobileWorkspace` into -`MobileSFTPSessionModel`. The Workspace retains connection/recovery and injects the connected file-system port. - -Desktop SSH, desktop Serial, and mobile SSH use one `TerminalFindController`. Connection Journey header/status/retry -sections and the Ghostty theme bridge are shared through Presentation; native logs, failure actions, keyboard -handling, and surface lifetimes stay platform-owned. Terminal upload readiness/notices and Known Host titles also -use common Application/Presentation policy. Assistant work was deliberately excluded from this pass. - -The complete iOS simulator action passes all 257 tests. Both app builds, 58 focused macOS architecture/theme/Journey/ -Find tests, and the new shared policy suites pass. The full macOS action retains the known Keychain environment, -Metrics native-focus, and Agent timing failures documented in `REFACTOR.md`; the Agent failures pass in focused -rerun. Strict formatting, Chinese localization, and `git diff --check` pass. No package version, schema, protocol, or -localized catalog changed. +- Exercise representative live SSH/SFTP, Live Activity rendering/taps, external + keyboards, lock/background expiration, and restoration on signed iPhone/iPad. +- Verify signed macOS/iOS CloudKit delivery and StoreKit Sandbox behavior, including + production schemas, account switching, delayed Keychain delivery, and entitlement + changes. +- Continue extracting reusable desktop SwiftUI where it reduces duplication without + crossing the native AppKit/UIKit boundary. diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index c56ba2ea..89d90060 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -1314,3 +1314,14 @@ popover focus, Agent cancellation, and approval timing. management rows benefit from system swipe actions and navigation behavior, while the unbounded streaming transcript continues to use ListViewKit. Active rows swipe to Archive and archived rows swipe to Unarchive; permanent deletion remains behind the context menu and confirmation dialog. + + +## 2026-09-12 — Share Assistant transcript presentation + +**Status:** In progress. + +**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. diff --git a/Memory/SETTINGS_SYNC_RELEASE.md b/Memory/SETTINGS_SYNC_RELEASE.md index b8c81954..10cdfb9c 100644 --- a/Memory/SETTINGS_SYNC_RELEASE.md +++ b/Memory/SETTINGS_SYNC_RELEASE.md @@ -1,46 +1,45 @@ # Settings sync release verification -Settings sync ships disabled by default under the Settings checkbox. Vault and Metrics are -selected by default. Vault keeps -its existing zone and record type. Settings retains the first AI-sync zone and -record identifiers for compatibility. No AgentKit dependency update is required. +Settings sync ships off by default; Vault and Metrics are selected, Settings is +opt-in. Vault keeps its existing protocol, Settings keeps its original AI-sync +identifiers, and no AgentKit bump is required. V13 names/defaults are frozen; V14 +adds Terminal preferences. Migration of a copied installed V13 store preserved row +counts and SQLite integrity, and the live store was observed healthy at V14. -V13 entity names/defaults are frozen. V14 adds Terminal preferences. The repair -was verified against an isolated copy of the installed V13 database: migration -to V14 succeeded, SQLite integrity was valid, and every preexisting table kept -its row count. The live database was subsequently observed at V14 with valid -integrity. +## CloudKit deployment -Before shipping the app and privacy policy 1.5 together: +In private container `iCloud.com.akinokaede.termind`, create/validate Development +schemas and deploy them to Production: -- In `iCloud.com.akinokaede.termind`, create/validate the Development - `AIConfigurationItem` record type, then deploy its schema to Production. Use the same - field types as `VaultItem`: `formatVersion`, `counter`, and `isDeleted` are - integers; `kind`, `entityID`, `writerID`, and `payloadHash` are strings; - `modifiedAt` is a date; `payload` is bytes; `payloadAsset` is an asset. - The private custom zone is `TermindAIConfigurationV1`; subscription ID is - `termind-ai-configuration-v1-changes`. No public database access is needed. -- Test two signed Macs using the same Apple account: add/edit/delete a provider, - role, MCP client, skill and Terminal configuration; exercise offline edits, - master/category pause and resume, delayed Keychain arrival and key rotation. -- Switch one Mac's iCloud account and confirm that settings upload waits for the - merge action. Verify that the other category's selection and account state are - independent. Repeat with the master disabled and after Pro expiration. -- Check light/dark themes, fonts, custom terminal text and shell-integration - changes on existing windows; ensure conversations and approval mode stay local. -- With different built-in MCP listener settings on both Macs, verify that enabled - state, port, exposed tools and bearer token remain independent. An older synchronized - listener token must be verified locally before its iCloud Keychain copy is removed. -- Match the privacy policy effective date to the actual release date before - publishing the website. The prepared date is September 12, 2026. +| Category | Zone | Record type | Subscription | +| --- | --- | --- | --- | +| Settings | `TermindAIConfigurationV1` | `AIConfigurationItem` | `termind-ai-configuration-v1-changes` | +| Metrics | `TermindMetricsV1` | `MetricsItem` | `termind-metrics-v1-changes` | -The implementation environment did not have a CloudKit management token -(`cktool get-teams` reported that none was available). Production schema -validation/deployment and two-Mac delivery verification require that environment -access; local tests do not establish real iCloud delivery. +Both record types use the Vault envelope: integer `formatVersion`, `counter`, +`isDeleted`; string `kind`, `entityID`, `writerID`, `payloadHash`; date +`modifiedAt`; bytes `payload`; asset `payloadAsset`. No public database access +is needed. -Metrics adds private zone `TermindMetricsV1`, record type `MetricsItem`, and subscription -`termind-metrics-v1-changes`. Before release, create and deploy that schema with the same -envelope field types as Vault and Settings, then verify ordered selections, pause state and -disk/network filters between signed macOS and iOS devices. Confirm a Metrics record received -before its Vault host remains hidden and appears after the host arrives. +The implementation environment had no CloudKit management token +(`cktool get-teams` found none), so local tests do not establish production schema +deployment or real delivery. + +## Signed-device release checks + +- On two Macs with one Apple account, add/edit/delete providers, roles, MCP clients, + skills, and Terminal configuration; include offline edits, category/master pause + and resume, delayed Keychain delivery, and key rotation. +- Switch one Mac's iCloud account and ensure upload waits for merge. Confirm each + category's selection/account state is independent; repeat with master sync off and + after Pro expiration. +- Confirm theme/font/custom terminal/shell-integration changes reach existing + windows, while conversations and approval mode remain local. +- Give each Mac different local MCP listener enablement, port, tools, and token. + Verify independence and verify an old synchronized token locally before removing + its iCloud Keychain copy. +- Across signed macOS/iOS devices, verify Metrics order, pause state, and disk/network + filters. A Metrics record received before its Vault Host must stay hidden until the + Host arrives. +- Release privacy policy 1.5 with the app and replace its prepared September 12, + 2026 effective date with the actual publication date. diff --git a/TFI/App/MobileRootView.swift b/TFI/App/MobileRootView.swift index 807880c9..25fcf775 100644 --- a/TFI/App/MobileRootView.swift +++ b/TFI/App/MobileRootView.swift @@ -181,7 +181,7 @@ struct MobileRootView: View { HStack(spacing: 12) { ZStack(alignment: .bottomTrailing) { Image(systemName: workspace.descriptor.kind.symbol) - .foregroundStyle(.secondary) + .foregroundStyle(.tint) .frame(width: 28, height: 28) Circle() .fill(workspaceStatusTint(workspace)) diff --git a/TFI/Application/Agent/MobileAssistantModel.swift b/TFI/Application/Agent/MobileAssistantModel.swift index 1a739c90..02bd89b6 100644 --- a/TFI/Application/Agent/MobileAssistantModel.swift +++ b/TFI/Application/Agent/MobileAssistantModel.swift @@ -102,7 +102,7 @@ final class MobileAssistantModel { func newConversation() { speech.cancelAll() - _ = library.selectNewConversation() + _ = library.resetDraftConversation() configure(conversation) } @@ -116,8 +116,13 @@ final class MobileAssistantModel { return } guard conversation.canSendDraft else { return } - _ = library.startConversation(conversation) - execution.sendAgentDraft(conversation) + if library.selectedConversation == nil { + guard let started = library.startDraftConversation() else { return } + configure(library.draftConversation) + execution.startAgentRun(started) + } else { + execution.sendAgentDraft(conversation) + } } func stop() { for conversation in store.conversations { conversation.stop() } } @@ -141,9 +146,13 @@ final class MobileAssistantModel { func waitForSuspension() async { await suspensionTask?.value } - func selectModel(_ value: AIModelRef?) { + func selectModel(_ value: AIModelRef) { + guard let resolved = configuration.lookup(value) else { return } conversation.model = value - if let value { configuration.select(value) } + configuration.select(value) + conversation.reasoningEffort = ModelCapabilityResolver.reasoning( + model: resolved.model, provider: resolved.provider + ).clamp(conversation.reasoningEffort) } func archive(_ conversation: ConversationModel, archived: Bool) async { @@ -167,6 +176,7 @@ final class MobileAssistantModel { let result = try await operations.delete(conversation) guard result.deleted else { return } library.delete(conversation.id) + if library.selectedConversation == nil { _ = library.resetDraftConversation() } configure(self.conversation) if let cleanupError = result.cleanupError { error = cleanupError } } diff --git a/TFI/Application/Transfers/MobileSFTPSessionModel.swift b/TFI/Application/Transfers/MobileSFTPSessionModel.swift index 60a7914a..be70455a 100644 --- a/TFI/Application/Transfers/MobileSFTPSessionModel.swift +++ b/TFI/Application/Transfers/MobileSFTPSessionModel.swift @@ -19,14 +19,16 @@ final class MobileSFTPSessionModel { private(set) var isRefreshing = false var sortField: RemoteFileSortField = .name var sortDirection: RemoteFileSortDirection = .ascending + var showsHiddenFiles = false var canGoBack: Bool { !backHistory.isEmpty } var canGoForward: Bool { !forwardHistory.isEmpty } var visibleFiles: [RemoteFile] { + let displayable = showsHiddenFiles ? files : files.filter { !$0.isHidden } let matches = query.isEmpty - ? files - : files.filter { $0.name.localizedCaseInsensitiveContains(query) } + ? displayable + : displayable.filter { $0.name.localizedCaseInsensitiveContains(query) } let descriptor = RemoteFileSortDescriptor(field: sortField, direction: sortDirection) return matches.sorted { descriptor.precedes($0, $1) } } diff --git a/TFI/Features/AI/MobileAssistantComposer.swift b/TFI/Features/AI/MobileAssistantComposer.swift index a807c9e1..625e6e0c 100644 --- a/TFI/Features/AI/MobileAssistantComposer.swift +++ b/TFI/Features/AI/MobileAssistantComposer.swift @@ -10,34 +10,43 @@ struct MobileAssistantComposer: View { let pro: ProAccessController let model: MobileAssistantModel @Bindable var conversation: ConversationModel + let focus: FocusState.Binding? + let focusRequest: Int? + let focusRequestHandled: (Int) -> Void @State private var files = false @State private var photos = false @State private var camera = false @State private var photoItems: [PhotosPickerItem] = [] @State private var photoTask: Task? @State private var speechDraft = "" - @State private var mcp = false @State private var contextDetails = false @State private var isSlashPanelDismissed = false @State private var slashPanelHeight: CGFloat = 0 - @FocusState private var focused: Bool + @FocusState private var localFocus: Bool @Environment(\.locale) private var locale private var composer: ConversationComposerState { conversation.composer } private var recording: Bool { model.speech.isOwned(by: conversation.id) } + private var activeFocus: FocusState.Binding { focus ?? $localFocus } + + init( + pro: ProAccessController, + model: MobileAssistantModel, + conversation: ConversationModel, + focus: FocusState.Binding? = nil, + focusRequest: Int? = nil, + focusRequestHandled: @escaping (Int) -> Void = { _ in } + ) { + self.pro = pro + self.model = model + self.conversation = conversation + self.focus = focus + self.focusRequest = focusRequest + self.focusRequestHandled = focusRequestHandled + } var body: some View { VStack(alignment: .leading, spacing: 10) { composerSurface - if let error = composer.attachmentImportError { - HStack(alignment: .top) { - Text(error).font(.caption).foregroundStyle(.red) - Button("Dismiss", systemImage: "xmark") { composer.attachmentImportError = nil } - } - } - if conversation.isCompacting { ProgressView("Compacting…") } - if case .failed(let error) = conversation.compactionActivity?.state { - Text(error).font(.caption).foregroundStyle(.red) - } ForEach(conversation.pendingSteering) { pending in HStack { Text(pending.draft).font(.caption).lineLimit(2) @@ -56,6 +65,17 @@ struct MobileAssistantComposer: View { case .failure(let error): composer.attachmentImportError = error.localizedDescription } } + .alert( + "Attachment Error", + isPresented: Binding( + get: { composer.attachmentImportError != nil }, + set: { if !$0 { composer.attachmentImportError = nil } } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text(composer.attachmentImportError ?? "") + } .photosPicker( isPresented: $photos, selection: $photoItems, maxSelectionCount: max(1, 20 - composer.attachments.count - composer.pendingAttachments.count), @@ -71,16 +91,6 @@ struct MobileAssistantComposer: View { if let data { conversation.importAttachmentImages([data], label: String(localized: "Photo")) } }.ignoresSafeArea() } - .sheet(isPresented: $mcp) { - NavigationStack { - MobileMCPSettings(ai: model.configuration, pro: pro) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Done", systemImage: "checkmark") { mcp = false }.labelStyle(.iconOnly) - } - } - }.mobileSheetPresentation(compact: true) - } .alert( model.speech.error?.title ?? "Transcription Failed", isPresented: Binding( @@ -100,6 +110,15 @@ struct MobileAssistantComposer: View { photoTask?.cancel() model.speech.cancel(ownerID: conversation.id) } + .task(id: focusRequest) { + guard let focusRequest else { return } + await Task.yield() + guard !Task.isCancelled else { return } + activeFocus.wrappedValue = true + focusRequestHandled(focusRequest) + } + .onAppear(perform: normalizeReasoningEffort) + .onChange(of: reasoningResolution) { _, _ in normalizeReasoningEffort() } } private var composerSurface: some View { @@ -113,10 +132,17 @@ struct MobileAssistantComposer: View { axis: .vertical ) .lineLimit(1...6) - .focused($focused) + .focused(activeFocus) .disabled(recording) .padding(.horizontal, 4) .padding(.top, 4) + .submitLabel(.send) + .onSubmit(submitFromKeyboard) + .onKeyPress(.escape, phases: .down) { _ in + guard !slashCompletion.isEmpty, !isSlashPanelDismissed else { return .ignored } + isSlashPanelDismissed = true + return .handled + } if recording { recordingControls } else { standardControls } } @@ -136,14 +162,6 @@ struct MobileAssistantComposer: View { hasOptions: { !slashOptions($0).isEmpty }, trailingText: slashTrailingText, isCurrent: isCurrentSlashOption, - onBack: { - composer.draft = "/" - focused = true - }, - onClose: { - isSlashPanelDismissed = true - focused = true - }, onSelect: acceptSlashRow ) .onGeometryChange(for: CGFloat.self) { proxy in @@ -178,7 +196,7 @@ struct MobileAssistantComposer: View { Button { composer.draft = speechDraft model.speech.cancel(ownerID: conversation.id) - focused = true + activeFocus.wrappedValue = true } label: { Label("Cancel", systemImage: "xmark").labelStyle(.iconOnly) .frame(width: 48, height: 48).contentShape(.circle) @@ -208,41 +226,49 @@ struct MobileAssistantComposer: View { .disabled(!UIImagePickerController.isSourceTypeAvailable(.camera)) Button("Photos", systemImage: "photo") { photos = true } Button("Files", systemImage: "doc") { files = true } - Divider() - Button("Voice Input", systemImage: "mic") { startSpeech() }.disabled(recording) } label: { Image(systemName: "plus").font(.title3.weight(.medium)) .foregroundStyle(.primary) .frame(width: 48, height: 48).contentShape(.circle) } + .buttonStyle(.plain) .accessibilityLabel("Add Attachment") } private var permissionMenu: some View { Menu { - Picker( - "Permissions", - selection: Binding( - get: { model.configuration.resolve(conversation: conversation.permissionMode) }, - set: { - conversation.permissionMode = $0 - model.configuration.select($0) - }) - ) { - ForEach(AgentPermissionMode.allCases) { mode in Text(mode.displayName).tag(mode) } + ForEach(AgentPermissionMode.allCases) { mode in + Button(role: mode == .fullAccess ? .destructive : nil) { + conversation.permissionMode = mode + model.configuration.select(mode) + } label: { + Label( + mode.displayName, + systemImage: mode == resolvedPermissionMode ? "checkmark" : mode.symbol + ) + } } } label: { - Image(systemName: model.configuration.resolve(conversation: conversation.permissionMode).symbol) + Label("Permissions", systemImage: resolvedPermissionMode.symbol) + .labelStyle(.iconOnly) .font(.title3) + .symbolRenderingMode(.monochrome) .foregroundStyle( - model.configuration.resolve(conversation: conversation.permissionMode) == .fullAccess - ? AnyShapeStyle(.red) : AnyShapeStyle(.secondary) + resolvedPermissionMode == .fullAccess + ? AnyShapeStyle(.red) : AnyShapeStyle(.primary) ) .frame(width: 48, height: 48).contentShape(.circle) } + .buttonStyle(.plain) + .fixedSize() + .layoutPriority(1) .accessibilityLabel("Permissions") } + private var resolvedPermissionMode: AgentPermissionMode { + model.configuration.resolve(conversation: conversation.permissionMode) + } + private var contextButton: some View { Button { contextDetails = true @@ -250,7 +276,8 @@ struct MobileAssistantComposer: View { ContextRing(share: contextShare ?? 0, diameter: 28, lineWidth: 4) .frame(width: 48, height: 48).contentShape(.circle) } - .accessibilityLabel("Context Tokens") + .buttonStyle(.plain) + .accessibilityLabel(Text(modelAndEffortAccessibilityLabel)) .accessibilityValue(Text(conversation.contextEstimate.total, format: .number)) } @@ -260,6 +287,7 @@ struct MobileAssistantComposer: View { .font(.system(size: 25, weight: .medium)).foregroundStyle(.primary) .frame(width: 48, height: 48).contentShape(.circle) } + .buttonStyle(.plain) .disabled(!model.speech.canStart(for: conversation.id)) } @@ -283,31 +311,34 @@ struct MobileAssistantComposer: View { NavigationStack { Form { if conversation.contextEstimate.total > 0 { - Section { + Section("Context") { ContextBreakdownView( estimate: conversation.contextEstimate, contextWindow: resolved?.model.contextLength, - cachedInputTokens: conversation.usage?.cachedInputTokens ?? 0 + cachedInputTokens: conversation.usage?.cachedInputTokens ?? 0, + showsTitle: false ) } } - Section("Model") { modelPicker } - if let reasoningResolution { - Section("Reasoning") { - Picker("Reasoning", selection: $conversation.reasoningEffort) { + Section("Model") { + assistantModelPicker + if supportsThinking, let reasoningResolution { + Picker("Effort", selection: $conversation.reasoningEffort) { ForEach(reasoningResolution.supported, id: \.self) { effort in Text(effort.displayName).tag(effort) } } - .pickerStyle(.inline) } } } - .navigationTitle("Context") + .navigationTitle("Model") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { - Button("Done") { contextDetails = false } + Button("Done", systemImage: "checkmark") { contextDetails = false } + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.primary) } } } @@ -370,108 +401,63 @@ struct MobileAssistantComposer: View { }.scrollIndicators(.hidden).fixedSize(horizontal: false, vertical: true) } - private var controls: some View { - ViewThatFits(in: .horizontal) { - HStack { - modelPicker - options - badges - Spacer() - usage - } - VStack(alignment: .leading) { - HStack { - modelPicker - options - Spacer() - usage - } - badges - } - }.font(.caption) - } - - private var modelPicker: some View { - Menu { - Picker("Model", selection: Binding(get: { conversation.model }, set: { model.selectModel($0) })) { - Text("Automatic").tag(nil as AIModelRef?) - ForEach(model.configuration.enabledModelProviders) { provider in - ForEach(provider.assistantModels) { item in - Text("\(provider.name) · \(item.label)").tag( - AIModelRef(modelProviderID: provider.id, modelID: item.id) as AIModelRef?) - } - } - } - if let resolved = model.configuration.resolve(conversation: conversation.model) { - Picker("Reasoning", selection: $conversation.reasoningEffort) { - ForEach( - ModelCapabilityResolver.reasoning(model: resolved.model, provider: resolved.provider).supported, - id: \.self - ) { effort in - Text(effort.rawValue).tag(effort) - } - } - } - } label: { - if let resolved = model.configuration.resolve(conversation: conversation.model) { - Label(resolved.model.label, systemImage: "cube").lineLimit(1) - } else { - Label("Model", systemImage: "cube") - } - } - } - - private var options: some View { - Menu { + @ViewBuilder private var assistantModelPicker: some View { + if modelProviders.isEmpty { + Text("No models configured").foregroundStyle(.secondary) + } else { Picker( - "Permissions", + "Model", selection: Binding( - get: { model.configuration.resolve(conversation: conversation.permissionMode) }, - set: { - conversation.permissionMode = $0 - model.configuration.select($0) - }) + get: { resolvedModelRef }, + set: { if let value = $0 { model.selectModel(value) } }) ) { - ForEach(AgentPermissionMode.allCases) { mode in Text(mode.displayName).tag(mode) } - } - Toggle("Plan", isOn: $conversation.isPlanning) - Menu("Skills", systemImage: "book.closed") { - ForEach(model.skills.enabledSkills) { skill in - Button(skill.name, systemImage: "book.closed") { composer.armedSkillName = skill.effectiveName } + ForEach(modelProviders) { provider in + Section(provider.name) { + ForEach(provider.assistantModels) { item in + Text(item.label).tag( + AIModelRef(modelProviderID: provider.id, modelID: item.id) as AIModelRef?) + } + } } } - Button("Compact", systemImage: "arrow.triangle.2.circlepath") { Task { await model.compact(conversation) } } - .disabled(conversation.isStreaming) - Button("MCP", systemImage: "network") { mcp = true } - } label: { - Image(systemName: model.configuration.resolve(conversation: conversation.permissionMode).symbol).frame( - minWidth: 44, minHeight: 44 - ).contentShape(.rect) } - .accessibilityLabel("Permissions") } @ViewBuilder private var badges: some View { if conversation.isPlanning { - Button("Plan", systemImage: "lightbulb.max") { conversation.isPlanning = false } - .buttonStyle(.plain).foregroundStyle(.secondary) + Button { + conversation.isPlanning = false + } label: { + Label("Plan", systemImage: "lightbulb.max") + .labelStyle(.iconOnly) + .frame(width: 48, height: 48) + .contentShape(.circle) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) } if let skill = composer.armedSkillName { - Button(skill, systemImage: "book.closed") { composer.armedSkillName = nil } - .buttonStyle(.plain).foregroundStyle(.secondary) + Button { + composer.armedSkillName = nil + } label: { + Label(skill, systemImage: "book.closed") + .labelStyle(.iconOnly) + .frame(width: 48, height: 48) + .contentShape(.circle) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(Text("Skill \(skill) armed, remove")) } } - private var usage: some View { - Menu { - Text("Context Tokens") - Text(conversation.contextEstimate.total, format: .number) - Button("Compact") { Task { await model.compact(conversation) } } - .disabled(conversation.isStreaming || conversation.isCompacting) - } label: { - Label(conversation.contextEstimate.total.formatted(), systemImage: "chart.pie") - .monospacedDigit().foregroundStyle(.secondary) - }.accessibilityLabel("Context Tokens") + private var modelProviders: [ModelProvider] { + model.configuration.enabledModelProviders.filter { !$0.assistantModels.isEmpty } + } + + private var resolvedModelRef: AIModelRef? { + guard let resolved else { return nil } + return AIModelRef(modelProviderID: resolved.provider.id, modelID: resolved.model.id) } private var resolved: (provider: ModelProvider, model: AIModel)? { @@ -487,10 +473,28 @@ struct MobileAssistantComposer: View { reasoningResolution?.clamp(conversation.reasoningEffort) ?? .off } + private var supportsThinking: Bool { + reasoningResolution?.supported.contains(where: { $0 != .off }) == true + } + + private var modelAndEffortAccessibilityLabel: String { + guard let resolved else { return String(localized: "Model, no model configured") } + guard supportsThinking else { return String(localized: "Model \(resolved.model.label)") } + return String( + localized: "Model \(resolved.model.label), effort \(effectiveReasoningEffort.displayName)" + ) + } + private var slashModelChoices: [(argument: String, provider: ModelProvider, model: AIModel)] { model.configuration.enabledModelProviders.flatMap { provider in provider.assistantModels.map { item in - ("\(provider.id.uuidString.lowercased()):\(item.id)", provider, item) + ( + ChatSlashCommand.argumentID( + scope: provider.id.uuidString.lowercased(), value: item.id + ), + provider, + item + ) } } } @@ -501,7 +505,8 @@ struct MobileAssistantComposer: View { commands.append( ChatSlashCommand( id: "new", displayName: localized("New chat"), - summary: localized("Start a new conversation"), icon: .symbol("plus.bubble") + summary: localized("Start a new conversation"), icon: .symbol("plus.bubble"), + isShownOnMobile: false )) } commands.append( @@ -517,7 +522,7 @@ struct MobileAssistantComposer: View { ChatSlashCommand( id: "compact", displayName: localized("Compact"), summary: localized("Summarize older turns to free context"), icon: .contextRing, - argumentHint: localized("what to emphasize") + argumentHint: localized("what to emphasize"), isShownOnMobile: true )) } if reasoningResolution?.supported.contains(where: { $0 != .off }) == true { @@ -525,7 +530,7 @@ struct MobileAssistantComposer: View { ChatSlashCommand( id: "reasoning", displayName: localized("Reasoning"), summary: localized("How hard the model thinks"), icon: .symbol("brain"), - argumentHint: localized("effort") + argumentHint: localized("effort"), isShownOnMobile: false )) } if !slashModelChoices.isEmpty { @@ -533,7 +538,7 @@ struct MobileAssistantComposer: View { ChatSlashCommand( id: "model", displayName: localized("Model"), summary: localized("Switch the model for this conversation"), icon: .symbol("sparkles"), - argumentHint: localized("model") + argumentHint: localized("model"), isShownOnMobile: false )) } if !model.configuration.mcpServers.isEmpty { @@ -541,7 +546,7 @@ struct MobileAssistantComposer: View { ChatSlashCommand( id: "mcp", displayName: localized("MCP"), summary: localized("Enable or disable MCP servers"), icon: .symbol("server.rack"), - argumentHint: localized("server") + argumentHint: localized("server"), isShownOnMobile: false )) } if !model.skills.enabledSkills.isEmpty { @@ -552,7 +557,7 @@ struct MobileAssistantComposer: View { argumentHint: localized("skill") )) } - return commands.sorted { lhs, rhs in + return commands.filter(\.isShownOnMobile).sorted { lhs, rhs in let order = lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) return order == .orderedSame ? lhs.id < rhs.id : order == .orderedAscending } @@ -615,7 +620,10 @@ struct MobileAssistantComposer: View { return option.id == effectiveReasoningEffort.rawValue case "model": guard let resolved else { return false } - return option.id == "\(resolved.provider.id.uuidString.lowercased()):\(resolved.model.id)" + return option.id + == ChatSlashCommand.argumentID( + scope: resolved.provider.id.uuidString.lowercased(), value: resolved.model.id + ) case "skill": return option.id == composer.armedSkillName default: @@ -636,13 +644,17 @@ struct MobileAssistantComposer: View { switch row { case .command(let command): let hasOptions = !slashOptions(command).isEmpty - composer.draft = ChatSlashCommandCompletion.accepted(row, takesArgument: hasOptions) + composer.draft = ChatSlashCommandCompletion.accepted( + row, takesArgument: hasOptions, in: composer.draft + ) if !hasOptions { _ = executeSlash() } case .option: - composer.draft = ChatSlashCommandCompletion.accepted(row, takesArgument: false) + composer.draft = ChatSlashCommandCompletion.accepted( + row, takesArgument: false, in: composer.draft + ) _ = executeSlash() } - focused = true + activeFocus.wrappedValue = true } private func send() { @@ -650,36 +662,45 @@ struct MobileAssistantComposer: View { model.send() } + private func submitFromKeyboard() { + if !isSlashPanelDismissed, let first = slashCompletion.rows.first { + acceptSlashRow(first) + } else { + send() + } + } + @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 = "" } + if case .argument(_, _, let text) = command { argument = text } else { argument = "" } switch command.name.lowercased() { case "new": - composer.draft = "" + composer.draft = command.leadingText model.newConversation() case "plan": - composer.draft = argument if argument.isEmpty { conversation.isPlanning.toggle() + composer.draft = command.leadingText } else { conversation.isPlanning = true - model.send() + composer.draft = command.leadingText + argument + if command.leadingText.isEmpty { model.send() } } case "compact": guard !conversation.isStreaming, !conversation.isCompacting else { return true } - composer.draft = "" + 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 = "" + composer.draft = command.leadingText } else if argument.isEmpty { - composer.draft = "/mcp " + composer.draft = command.replacingCommand(with: "/mcp ") } case "reasoning": if let effort = ReasoningEffort(rawValue: argument.lowercased()), @@ -688,36 +709,45 @@ struct MobileAssistantComposer: View { .contains(effort) { conversation.reasoningEffort = effort - composer.draft = "" + composer.draft = command.leadingText } else if argument.isEmpty { - composer.draft = "/reasoning " + composer.draft = command.replacingCommand(with: "/reasoning ") } case "model": for provider in model.configuration.enabledModelProviders { if let item = provider.assistantModels.first(where: { - "\(provider.id.uuidString.lowercased()):\($0.id)" == argument + ChatSlashCommand.argumentID( + scope: provider.id.uuidString.lowercased(), value: $0.id + ) == argument }) { model.selectModel(.init(modelProviderID: provider.id, modelID: item.id)) - composer.draft = "" + composer.draft = command.leadingText return true } } - if argument.isEmpty { composer.draft = "/model " } + 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 = choice.remainder - if !choice.remainder.isEmpty { model.send() } + composer.draft = command.leadingText + choice.remainder + if command.leadingText.isEmpty, !choice.remainder.isEmpty { model.send() } } else if argument.isEmpty { - composer.draft = "/skill " + composer.draft = command.replacingCommand(with: "/skill ") } default: break } return true } + private func normalizeReasoningEffort() { + let normalized = effectiveReasoningEffort + if conversation.reasoningEffort != normalized { + conversation.reasoningEffort = normalized + } + } + private func importPhotos(_ items: [PhotosPickerItem]) async { for item in items { guard !Task.isCancelled, let pending = conversation.beginAttachmentImport(label: String(localized: "Photo")) @@ -739,7 +769,7 @@ struct MobileAssistantComposer: View { private func startSpeech() { speechDraft = composer.draft - focused = false + activeFocus.wrappedValue = false Task { await model.speech.start(ownerID: conversation.id) { text, _ in guard model.conversation.id == conversation.id else { return } diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 1cfdefd8..db269e1c 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -7,46 +7,292 @@ import TermindPresentation import TermindRemoteInfrastructure import UniformTypeIdentifiers +private struct MobileConversationAttachmentDropModifier: ViewModifier { + let conversation: ConversationModel + let isEnabled: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isTargeted = false + + @ViewBuilder + func body(content: Content) -> some View { + if isEnabled { + content + .overlay { + if isTargeted { + DropTargetOverlay( + presentation: DropTargetPresentation( + title: String(localized: "Attach to Chat"), + detail: String(localized: "Files and images"), + systemImage: "paperclip" + ) + ) + } + } + .contentShape(.rect) + .onDrop( + of: [.fileURL, .image], + isTargeted: $isTargeted, + perform: receive + ) + .animation( + reduceMotion ? nil : .spring(duration: 0.28, bounce: 0.16), + value: isTargeted + ) + } else { + content + } + } + + private func receive(_ providers: [NSItemProvider]) -> Bool { + let kinds: Set = [.files, .images] + guard MobileDropPayloadLoader.accepts(providers, kinds: kinds) else { return false } + Task { + let payload = await MobileDropPayloadLoader.load(providers, kinds: kinds) + if !payload.fileURLs.isEmpty { + conversation.importAttachmentFiles(payload.fileURLs) + } + if !payload.images.isEmpty { + conversation.importAttachmentImages( + payload.images, label: String(localized: "Dropped image") + ) + } + if !payload.failures.isEmpty { + conversation.composer.attachmentImportError = [ + conversation.composer.attachmentImportError, + payload.failures.joined(separator: "\n"), + ] + .compactMap { $0 } + .joined(separator: "\n") + } + } + return true + } +} + +extension View { + fileprivate func mobileConversationAttachmentDropTarget( + conversation: ConversationModel, + isEnabled: Bool + ) -> some View { + modifier( + MobileConversationAttachmentDropModifier( + conversation: conversation, + isEnabled: isEnabled + ) + ) + } +} + +/// Wraps message images while keeping every row anchored to the message role's edge. +/// +/// An adaptive `LazyVGrid` reserves all columns that fit the available width, so a +/// partially filled user row starts in the first (leading) column. This layout only +/// reserves the columns used by each row, matching TFM's trailing image flow. +struct MobileAssistantImageFlowLayout: Layout { + enum Alignment { + case leading + case trailing + } + + struct Placement { + let index: Int + let origin: CGPoint + let size: CGSize + } + + let alignment: Alignment + var spacing: CGFloat = 8 + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let naturalWidth = sizes.enumerated().reduce(CGFloat.zero) { total, item in + total + item.element.width + (item.offset == 0 ? 0 : spacing) + } + let width = proposal.width.map { max(0, $0) } ?? naturalWidth + let placements = Self.placements( + for: sizes, + in: width, + spacing: spacing, + alignment: alignment + ) + let height = placements.map { $0.origin.y + $0.size.height }.max() ?? 0 + return CGSize(width: width, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + let placements = Self.placements( + for: subviews.map { $0.sizeThatFits(.unspecified) }, + in: bounds.width, + spacing: spacing, + alignment: alignment + ) + for placement in placements { + subviews[placement.index].place( + at: CGPoint( + x: bounds.minX + placement.origin.x, + y: bounds.minY + placement.origin.y + ), + anchor: .topLeading, + proposal: ProposedViewSize(placement.size) + ) + } + } + + static func placements( + for itemSizes: [CGSize], + in availableWidth: CGFloat, + spacing: CGFloat, + alignment: Alignment + ) -> [Placement] { + struct Item { + let index: Int + let size: CGSize + } + struct Row { + var items: [Item] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + let availableWidth = max(0, availableWidth) + var rows: [Row] = [] + var current = Row() + + for (index, itemSize) in itemSizes.enumerated() { + let size = CGSize(width: min(itemSize.width, availableWidth), height: itemSize.height) + let nextWidth = current.width + (current.items.isEmpty ? 0 : spacing) + size.width + if !current.items.isEmpty, nextWidth > availableWidth { + rows.append(current) + current = Row() + } + current.width += (current.items.isEmpty ? 0 : spacing) + size.width + current.height = max(current.height, size.height) + current.items.append(Item(index: index, size: size)) + } + if !current.items.isEmpty { rows.append(current) } + + var placements: [Placement] = [] + var y: CGFloat = 0 + for row in rows { + var x = alignment == .trailing ? max(0, availableWidth - row.width) : 0 + for item in row.items { + placements.append( + Placement(index: item.index, origin: CGPoint(x: x, y: y), size: item.size) + ) + x += item.size.width + spacing + } + y += row.height + spacing + } + return placements + } +} + +struct MobileAssistantUserBubble: View { + let text: String + + var body: some View { + ViewThatFits(in: .horizontal) { + content.fixedSize(horizontal: true, vertical: false) + content + } + } + + private var content: some View { + MobileAssistantMarkdown(text: text) + .textSelection(.enabled) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + .tint.opacity(0.1), + in: RoundedRectangle( + cornerRadius: AssistantTranscriptStyle.bubbleRadius, + style: .continuous + ) + ) + } +} + +struct MobileAssistantEditMessageTextEditor: View { + @Binding var text: String + + var body: some View { + TextEditor(text: $text) + .scrollContentBackground(.hidden) + .padding(12) + .background( + Color(uiColor: .secondarySystemBackground), + in: RoundedRectangle(cornerRadius: 16, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.primary.opacity(0.1), lineWidth: 1) + } + } +} + struct MobileAssistantConversation: View { let app: MobileAppModel let model: MobileAssistantModel @Bindable var conversation: ConversationModel + let composerFocus: FocusState.Binding + let composerFocusRequest: Int? + let composerFocusRequestHandled: (Int) -> Void @State private var editing: ChatMessage? @State private var editedText = "" + @State private var presentation = AssistantTranscriptPresentationState() + @Environment(\.locale) private var locale + @Environment(\.horizontalSizeClass) private var sizeClass @State private var preview: URL? @State private var previewDirectory = FileManager.default.temporaryDirectory.appending( path: "Assistant-Previews/\(UUID().uuidString)") var body: some View { + let entries = transcriptEntries + let revisions = Dictionary( + uniqueKeysWithValues: entries.map { + ($0.id, presentation.layoutRevision(for: $0.presentationIDs)) + }) MobileNativeList( - items: transcriptEntries, + items: entries, estimatedRowHeight: 132, refreshID: transcriptRefreshID, - followsBottom: true + followsBottom: true, + surface: .plain, + rowRevision: { revisions[$0.id] ?? 0 }, + prepareUpdate: { old, new, visible in + presentation.prepareWorkCompletionTransitions(from: old, to: new, visibleIDs: visible) + }, + onViewportChange: { presentation.collapseWorkOutsideViewport(visibleIDs: $0) }, + onTap: { composerFocus.wrappedValue = false } ) { entry in transcriptRow(entry) .padding(.horizontal, 16) .padding(.vertical, 10) - .frame(maxWidth: 850) + .frame(maxWidth: 840) .frame(maxWidth: .infinity) } .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 8) { - if conversation.isArchived { - Label("Archived conversations are read-only.", systemImage: "archivebox") - Button("Unarchive", systemImage: "tray.and.arrow.up") { - Task { await model.archive(conversation, archived: false) } - } - } else { - if !conversation.tasks.isEmpty { taskList } - MobileAssistantInteraction(conversation: conversation, hosts: app.library) - MobileAssistantComposer(pro: app.pro, model: model, conversation: conversation) - } - }.padding(12).background(.bar) + bottomSurface } + .background(Color(uiColor: .systemBackground)) + .mobileConversationAttachmentDropTarget( + conversation: conversation, + isEnabled: acceptsAttachmentDrops + ) .sheet(item: $editing) { message in NavigationStack { - TextEditor(text: $editedText).padding() + MobileAssistantEditMessageTextEditor(text: $editedText) + .padding() .navigationTitle("Edit Message") .toolbar { ToolbarItem(placement: .cancellationAction) { @@ -66,10 +312,19 @@ struct MobileAssistantConversation: View { .onDisappear { try? FileManager.default.removeItem(at: previewDirectory) } + .onChange(of: validPresentationIDs) { _, valid in + presentation.prune(validTimelineIDs: valid) + } } private var transcriptRefreshID: String { - "\(conversation.id.uuidString):\(conversation.isArchived):\(conversation.isStreaming):\(conversation.timeline.count)" + "\(conversation.id.uuidString):\(conversation.isArchived):\(conversation.isStreaming)" + } + + private var acceptsAttachmentDrops: Bool { + !conversation.isArchived + && conversation.pendingApproval == nil + && conversation.pendingUserInteraction == nil } private var transcriptEntries: [AssistantTranscriptEntry] { @@ -81,6 +336,73 @@ struct MobileAssistantConversation: View { ) } + private var validPresentationIDs: Set { + transcriptEntries.reduce(into: []) { $0.formUnion($1.presentationIDs) } + } + + @ViewBuilder private var bottomSurface: some View { + if conversation.isArchived { + archivedNotice + .padding(12) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(.primary.opacity(0.08), lineWidth: 1) + } + .padding(12) + } else if conversation.pendingApproval != nil || conversation.pendingUserInteraction != nil { + MobileAssistantInteraction(conversation: conversation, hosts: app.library) + .padding(12) + .background(Color(uiColor: .systemBackground)) + } else { + VStack(spacing: 8) { + if !conversation.tasks.isEmpty { taskList } + MobileAssistantComposer( + pro: app.pro, + model: model, + conversation: conversation, + focus: composerFocus, + focusRequest: composerFocusRequest, + focusRequestHandled: composerFocusRequestHandled + ) + } + .padding(12) + .background(Color(uiColor: .systemBackground)) + } + } + + private var archivedNotice: some View { + ViewThatFits(in: .horizontal) { + archivedNoticeContent + VStack(alignment: .leading, spacing: 12) { + archivedNoticeLabel + unarchiveButton.frame(maxWidth: .infinity, alignment: .trailing) + } + } + } + + private var archivedNoticeContent: some View { + HStack(spacing: 12) { + archivedNoticeLabel + Spacer(minLength: 12) + unarchiveButton + } + } + + private var archivedNoticeLabel: some View { + Label("Archived conversations are read-only.", systemImage: "archivebox") + .font(.callout) + .foregroundStyle(.secondary) + } + + private var unarchiveButton: some View { + Button("Unarchive", systemImage: "arrow.uturn.backward") { + Task { await model.archive(conversation, archived: false) } + } + .buttonStyle(.borderedProminent) + .fixedSize() + } + private var taskList: some View { DisclosureGroup("Tasks") { ForEach(conversation.tasks) { task in @@ -95,7 +417,10 @@ struct MobileAssistantConversation: View { @ViewBuilder private func transcriptRow(_ entry: AssistantTranscriptEntry) -> some View { switch entry.content { case .empty: - ContentUnavailableView("Ask anything…", systemImage: "sparkles").frame(minHeight: 180) + VStack(alignment: .leading, spacing: 12) { + Text("Start a Conversation").font(.headline) + Text("Ask a question or describe what you want to do.").foregroundStyle(.secondary) + }.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 24) case .user(let message, _), .assistant(let message, _): messageRow(message) case .tool(let card): @@ -111,7 +436,7 @@ struct MobileAssistantConversation: View { case .compactionActivity(let activity): timelineRow(.compactionActivity(activity)) case .interrupted: - Label("This run was interrupted and was not resumed.", systemImage: "exclamationmark.circle") + Label("This run was interrupted and was not resumed.", systemImage: "pause.circle") .font(.caption).foregroundStyle(.secondary) } } @@ -121,87 +446,156 @@ struct MobileAssistantConversation: View { case .message(let message): messageRow(message) case .tool(let card): toolRow(card) case .plan(let card): - GroupBox { - VStack(alignment: .leading, spacing: 12) { - Text(card.plan.title).font(.headline) - AssistantMessageBody(card.plan.body).textSelection(.enabled) - if card.outcome == .pending && !conversation.isArchived { - HStack { - Button("Implement", systemImage: "play") { model.implement(card, in: conversation) } - .disabled(conversation.isStreaming) - Button("Discard", systemImage: "xmark") { - conversation.resolvePlan(card.id, as: .discarded) - } - } - } - } - } + MobileAssistantPlanRow( + card: card, presentation: presentation, readOnly: conversation.isArchived, + isStreaming: conversation.isStreaming, + implement: { model.implement(card, in: conversation) }, + discard: { conversation.resolvePlan(card.id, as: .discarded) } + ) case .work(let group): - DisclosureGroup("Worked") { - ForEach(group.items) { item in - switch item { - case .reasoning(let block): AssistantMessageBody(block.text).textSelection(.enabled) - case .message(let message): messageRow(message) - case .tool(let card): toolRow(card) - case .tools(let batch): - if let reasoning = batch.reasoning { AssistantMessageBody(reasoning.markdown) } - ForEach(batch.cards) { card in toolRow(card) } - } - } - } - case .command(let command): Text(command.command).font(.body.monospaced()).textSelection(.enabled) - case .compactionActivity: ProgressView("Compacting…") + MobileAssistantWorkRow(group: group, presentation: presentation, hostName: hostName) + case .command(let command): MobileAssistantCommandRow(command: command) + case .compactionActivity(let activity): compactionActivityRow(activity) case .compaction(let mark): - Label("Compacted", systemImage: "arrow.triangle.2.circlepath").font(.caption).foregroundStyle(.secondary) - .accessibilityValue(Text(mark.summarizedTokens, format: .number)) + compactionRow(mark) } } + private func compactionRow(_ mark: AgentCompactionMark) -> some View { + HStack(spacing: 8) { + compactionRule + Label( + AppLocalization.string( + "Compacted — \(ContextBreakdownView.compact(mark.summarizedTokens)) summarized to \(ContextBreakdownView.compact(mark.keptTokens))", + locale: locale + ), + systemImage: "doc.text" + ) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + compactionRule + } + .accessibilityHint(Text("Earlier turns are summarized for the model. Nothing was deleted.")) + } + + private var compactionRule: some View { + Color(uiColor: .separator) + .frame(maxWidth: .infinity) + .frame(height: 1) + .accessibilityHidden(true) + } + + @ViewBuilder private func compactionActivityRow(_ activity: AgentCompactionActivity) -> some View { + switch activity.state { + case .running: + HStack(spacing: 8) { + Image(systemName: "doc.text.magnifyingglass") + Text("Compacting context").modifier(MobileAssistantScanning(active: true)) + Spacer() + } + .font(.callout) + .foregroundStyle(.secondary) + case .failed(let reason): + DisclosureGroup(isExpanded: disclosureBinding(for: .compactionActivity(activity.id))) { + HStack(alignment: .top, spacing: 12) { + Rectangle() + .fill(Color(uiColor: .separator)) + .frame(width: 1) + .accessibilityHidden(true) + Text(reason) + .font(.caption.monospaced()) + .foregroundStyle(.red) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.leading, 24) + } label: { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.red) + Text("Couldn’t compact context") + .foregroundStyle(.primary) + } + .font(.callout) + } + .tint(.secondary) + .accessibilityHint(Text("Show failure details")) + } + } + + private var imageTileSize: CGFloat { sizeClass == .compact ? 104 : 160 } + private func messageRow(_ message: ChatMessage) -> some View { VStack(alignment: .leading, spacing: 8) { - if message.role != .user { - Text("Assistant").font(.caption).foregroundStyle(.secondary) - } if !message.reasoning.isEmpty { - DisclosureGroup("Thinking") { - ForEach(message.reasoning) { block in AssistantMessageBody(block.text).textSelection(.enabled) } + transcriptDisclosure("Thinking", id: message.id) { + ForEach(message.reasoning) { block in MobileAssistantMarkdown(text: block.text, secondary: true) } + } + } + if !message.images.isEmpty { + MobileAssistantImageFlowLayout( + alignment: message.role == .user ? .trailing : .leading + ) { + ForEach(message.images) { image in + Button { + open(data: image.data, name: image.label) + } label: { + if let uiImage = UIImage(data: image.data) { + Image(uiImage: uiImage).resizable().scaledToFill() + .frame(width: imageTileSize, height: imageTileSize) + .clipShape(.rect(cornerRadius: 12)) + .accessibilityLabel(image.label) + } else { + Label(image.label, systemImage: "photo") + } + }.buttonStyle(.plain) + } + } + .frame( + maxWidth: .infinity, + alignment: message.role == .user ? .trailing : .leading + ) + } + ForEach(message.contextChips) { chip in + HStack { + if message.role == .user { Spacer(minLength: 0) } + Button { + if let attachment = chip.attachment { + open(data: attachment.data, name: attachment.suggestedFilename) + } else if let image = message.images.first(where: { $0.id == chip.id || $0.label == chip.label } + ) { + open(data: image.data, name: image.label) + } + } label: { + Label(chip.label, systemImage: chip.kind.symbol) + .font(.caption).padding(.horizontal, 8).padding(.vertical, 6) + .background(.quaternary, in: .rect(cornerRadius: 8)) + } + .buttonStyle(.plain).foregroundStyle(.secondary) + .disabled( + chip.attachment == nil + && !message.images.contains { $0.id == chip.id || $0.label == chip.label }) + if message.role != .user { Spacer(minLength: 0) } } } if !message.text.isEmpty { if message.role == .user { HStack { Spacer(minLength: 44) - AssistantMessageBody(message.text) - .textSelection(.enabled) - .padding(.horizontal, 14).padding(.vertical, 10) - .background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + MobileAssistantUserBubble(text: message.text) } } else { - AssistantMessageBody(message.text).textSelection(.enabled) + MobileAssistantMarkdown(text: message.text).textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } } - ForEach(message.images) { image in - Button { - open(data: image.data, name: image.label) - } label: { - if let uiImage = UIImage(data: image.data) { - Image(uiImage: uiImage).resizable().scaledToFit().frame(maxHeight: 220).clipShape( - .rect(cornerRadius: 12)) - } else { - Label(image.label, systemImage: "photo") - } - }.buttonStyle(.plain) - } - ForEach(message.contextChips) { chip in - if let attachment = chip.attachment { - Button { - open(data: attachment.data, name: attachment.suggestedFilename) - } label: { - Label(chip.label, systemImage: "doc") - } - } + if message.isStreaming && message.text.isEmpty && message.reasoning.isEmpty { + MobileAssistantActivity(cyclesText: true) } + if !message.isStreaming { messageActions(message) } + ForEach(message.commands) { command in MobileAssistantCommandRow(command: command) } } .contextMenu { Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = message.text } @@ -220,40 +614,87 @@ struct MobileAssistantConversation: View { } } - private func toolRow(_ card: AgentToolCard) -> some View { - DisclosureGroup { - VStack(alignment: .leading, spacing: 8) { - Text(card.descriptor.summary).font(.caption) - Text(card.invocation.call.arguments.displayText).font(.caption.monospaced()).textSelection(.enabled) - if let result = card.result { - AssistantToolDetails(items: detailItems(card, result: result)) + private func messageActions(_ message: ChatMessage) -> some View { + HStack(spacing: 4) { + if message.role == .user { + Spacer(minLength: 0) + timestamp(message) + } + + if conversation.canEditUserMessage(message.id) { + messageAction("Edit", systemImage: "pencil") { + editedText = message.text + editing = message } } - } label: { - HStack { - Image(systemName: card.descriptor.presentation?.symbol ?? "wrench.and.screwdriver") - Text(card.descriptor.name) - Spacer() - if !card.state.isTerminal { - ProgressView().controlSize(.small) - } else { - Image(systemName: card.state == .completed ? "checkmark.circle" : "exclamationmark.circle") + + messageAction("Copy", systemImage: "doc.on.doc") { + UIPasteboard.general.string = message.text + } + + if conversation.canForkAssistantMessage(message.id) { + messageAction("Fork", systemImage: "arrow.triangle.branch") { + Task { await model.fork(conversation, at: message.id) } } - }.font(.callout) + } + + if message.role != .user { + timestamp(message) + Spacer(minLength: 0) + } } + .frame(maxWidth: .infinity, minHeight: 44) } - private func detailItems(_ card: AgentToolCard, result: AgentToolResult) -> [AgentToolDetail.Item] { - let raw = result.untrustedPayload ?? result.content - guard card.descriptor.target != .mcp, let value = try? AgentJSONValue.decode(Data(raw.utf8)) else { - return [.text(.init(text: raw, style: .monospaced))] + private func messageAction( + _ title: LocalizedStringKey, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .font(.caption) + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(.rect) } - let presenter = - card.descriptor.presentation?.presenterID.flatMap { TermindAgentTools.presenter(id: $0) } - ?? TermindAgentTools.legacyPresenter(forToolNamed: card.descriptor.name) - return presenter?.present( - .init(result: value, arguments: card.invocation.call.arguments.objectValue ?? [:], locale: .current)) - ?? AgentToolDetailFormatting.genericItems(value, locale: .current) + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + + private func timestamp(_ message: ChatMessage) -> some View { + Text(ChatMessageTimestampFormatter.string(for: message.createdAt, locale: locale)) + .font(.caption.monospacedDigit()).foregroundStyle(.tertiary) + } + + private func hostName(_ id: UUID) -> String { + app.library.host(id)?.name ?? String(id.uuidString.prefix(8)) + } + + private func toolRow(_ card: AgentToolCard) -> some View { + MobileAssistantToolRow(card: card, presentation: presentation, hostName: hostName) + } + + private func transcriptDisclosure( + _ title: LocalizedStringKey, + id: UUID, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + MobileAssistantDisclosure( + expanded: presentation.isExpanded(.reasoning(id)), + toggle: { presentation.toggleDisclosure(.reasoning(id)) }, + title: { Text(title).foregroundStyle(.secondary) }, + content: content + ) + } + + private func disclosureBinding(for id: AssistantTranscriptDisclosureID) -> Binding { + Binding( + get: { presentation.isExpanded(id) }, + set: { expanded in + if expanded != presentation.isExpanded(id) { presentation.toggleDisclosure(id) } + } + ) } private func open(data: Data, name: String) { diff --git a/TFI/Features/AI/MobileAssistantInteraction.swift b/TFI/Features/AI/MobileAssistantInteraction.swift index 155ddbf9..18ae2264 100644 --- a/TFI/Features/AI/MobileAssistantInteraction.swift +++ b/TFI/Features/AI/MobileAssistantInteraction.swift @@ -12,25 +12,27 @@ struct MobileAssistantInteraction: View { if let approval = conversation.pendingApproval, conversation.pendingUserInteraction.map({ approval.invocation.createdAt <= $0.createdAt }) ?? true { - VStack(alignment: .leading, spacing: 8) { - Text("Approval Required").font(.headline) - Text(approval.descriptor.name).font(.callout.monospaced()) - ScrollView { + MobileAssistantAdaptiveHeight { + VStack(alignment: .leading, spacing: 8) { + Text("Approval Required").font(.headline) + Text(approval.descriptor.name).font(.callout.monospaced()) Text(approval.invocation.call.arguments.displayText).font(.caption.monospaced()).textSelection( .enabled ) .frame(maxWidth: .infinity, alignment: .leading) - }.frame(maxHeight: 160) - ForEach(approval.localReasons, id: \.self) { Text($0).font(.caption) } - HStack { - Button("Deny", systemImage: "xmark", role: .cancel) { conversation.respondToApproval(allow: false) } - Spacer() - Button("Allow", systemImage: "checkmark") { conversation.respondToApproval(allow: true) } - .buttonStyle(.borderedProminent) + ForEach(approval.localReasons, id: \.self) { Text($0).font(.caption) } + HStack { + Button("Deny", systemImage: "xmark", role: .cancel) { + conversation.respondToApproval(allow: false) + } + Spacer() + Button("Allow", systemImage: "checkmark") { conversation.respondToApproval(allow: true) } + .buttonStyle(.borderedProminent) + } } } } else if let interaction = conversation.pendingUserInteraction { - ScrollView { + MobileAssistantAdaptiveHeight { switch interaction { case .sshAuthentication(let request): MobileAssistantAuthenticationForm(request: request, conversation: conversation, hosts: hosts) @@ -39,7 +41,7 @@ struct MobileAssistantInteraction: View { case .questions: questions } - }.frame(maxHeight: 300) + } } } @@ -57,7 +59,7 @@ struct MobileAssistantInteraction: View { case .choice(let choice): ForEach(choice.options) { option in Button { - conversation.toggleUserInputOption(option.id, in: question.id) + conversation.answerUserInputOption(option.id, in: question.id) } label: { HStack(alignment: .top) { Image( @@ -104,6 +106,28 @@ struct MobileAssistantInteraction: View { } } +private struct MobileAssistantAdaptiveHeight: View { + @ViewBuilder let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ViewThatFits(in: .vertical) { + content + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + ScrollView { + content.frame(maxWidth: .infinity, alignment: .leading) + } + .scrollBounceBehavior(.basedOnSize) + } + .frame(maxWidth: .infinity, maxHeight: 300, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + } +} + private struct MobileAssistantAuthenticationForm: View { let request: AgentSSHAuthenticationRequest let conversation: ConversationModel diff --git a/TFI/Features/AI/MobileAssistantTranscriptCards.swift b/TFI/Features/AI/MobileAssistantTranscriptCards.swift new file mode 100644 index 00000000..f71452d9 --- /dev/null +++ b/TFI/Features/AI/MobileAssistantTranscriptCards.swift @@ -0,0 +1,122 @@ +import AgentKit +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation + +struct MobileAssistantPlanRow: View { + let card: AgentPlanCard + let presentation: AssistantTranscriptPresentationState + let readOnly: Bool + let isStreaming: Bool + let implement: () -> Void + let discard: () -> Void + + private var expanded: Bool { presentation.isExpanded(.plan(card.id)) } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Button { + presentation.toggleDisclosure(.plan(card.id)) + } label: { + HStack(spacing: 8) { + Image(systemName: symbol).foregroundStyle(tint) + Text(card.plan.title).font(.headline).lineLimit(expanded ? nil : 1) + Spacer(minLength: 0) + if card.outcome == .implemented { + Text("Implemented").font(.caption).foregroundStyle(.secondary) + } else if card.outcome == .discarded { + Text("Discarded").font(.caption).foregroundStyle(.secondary) + } + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.caption2).foregroundStyle(.tertiary) + } + .frame(minHeight: 44).contentShape(.rect) + }.buttonStyle(.plain) + MobileAssistantMarkdown(text: card.plan.body) + .frame(maxHeight: expanded ? nil : 112, alignment: .top).clipped() + .contentShape(.rect) + .onTapGesture { if !expanded { presentation.toggleDisclosure(.plan(card.id)) } } + if card.outcome == .pending && !readOnly { + ViewThatFits(in: .horizontal) { + HStack { + actions + Text("Reply to keep planning").font(.caption).foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 8) { + HStack { actions } + Text("Reply to keep planning").font(.caption).foregroundStyle(.secondary) + } + } + } + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 12)) + .overlay { RoundedRectangle(cornerRadius: 12).stroke(Color(uiColor: .separator), lineWidth: 1) } + } + + @ViewBuilder private var actions: some View { + Button("Discard", role: .destructive, action: discard).frame(minHeight: 44) + Spacer(minLength: 12) + Button("Implement", systemImage: "play", action: implement) + .buttonStyle(.borderedProminent).disabled(isStreaming) + } + + private var symbol: String { + switch card.outcome { + case .pending: "lightbulb.max" + case .implemented: "checkmark.circle.fill" + case .discarded: "xmark.circle" + } + } + + private var tint: Color { + switch card.outcome { + case .pending: .accentColor + case .implemented: .green + case .discarded: .secondary + } + } +} + +struct MobileAssistantCommandRow: View { + let command: SuggestedCommand + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("Suggested Command", systemImage: "sparkles").foregroundStyle(.secondary) + Spacer(minLength: 8) + Label(command.risk.risk.label, systemImage: command.risk.risk.symbol).foregroundStyle(riskColor) + }.font(.caption.weight(.medium)) + Text(command.command).font(.callout.monospaced()).textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading).padding(10) + if !command.explanation.isEmpty { + Text(command.explanation).font(.caption).foregroundStyle(.secondary) + } + ForEach(command.risk.reasons, id: \.self) { reason in + Text(verbatim: "• \(reason)").font(.caption2).foregroundStyle(riskColor) + } + HStack { + Spacer() + Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = command.command } + .font(.caption).frame(minHeight: 44) + } + } + .padding(16) + .overlay { + RoundedRectangle(cornerRadius: 12) + .stroke( + command.risk.risk == .destructive ? Color.red.opacity(0.4) : Color(uiColor: .separator), + lineWidth: 1) + } + } + + private var riskColor: Color { + switch command.risk.risk { + case .safe: .green + case .caution: .orange + case .destructive: .red + } + } +} diff --git a/TFI/Features/AI/MobileAssistantTranscriptStyle.swift b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift new file mode 100644 index 00000000..8e42d64c --- /dev/null +++ b/TFI/Features/AI/MobileAssistantTranscriptStyle.swift @@ -0,0 +1,132 @@ +import MarkdownView +import SwiftUI +import TermindPresentation +import UIKit + +struct MobileAssistantMarkdown: View { + let text: String + var secondary = false + @ScaledMetric private var bodySize: CGFloat = 17 + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + AssistantMessageBody(text, theme: theme).textSelection(.enabled) + } + + private var theme: MarkdownTheme { + var value = MarkdownTheme() + value.fonts.body = .systemFont(ofSize: bodySize) + value.fonts.bold = .boldSystemFont(ofSize: bodySize) + value.fonts.italic = .italicSystemFont(ofSize: bodySize) + value.fonts.codeInline = .monospacedSystemFont(ofSize: bodySize, weight: .regular) + value.fonts.code = .monospacedSystemFont(ofSize: ceil(bodySize * MarkdownTheme.codeScale), weight: .regular) + value.fonts.title = .boldSystemFont(ofSize: bodySize + 2) + value.fonts.largeTitle = .boldSystemFont(ofSize: bodySize + 5) + let traits = UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light) + let color = (secondary ? UIColor.secondaryLabel : .label).resolvedColor(with: traits) + value.colors.body = color + value.colors.code = color + value.colors.highlight = UIColor(named: "AccentColor") ?? .systemBlue + value.colors.emphasis = UIColor(named: "AccentColor") ?? .systemBlue + value.colors.codeBackground = UIColor.secondaryLabel.resolvedColor(with: traits).withAlphaComponent(0.15) + value.spacings.paragraph = AssistantTranscriptStyle.blockSpacing + value.spacings.headingBefore = 4 + value.spacings.list = 4 + value.table.cornerRadius = 4 + value.table.borderColor = UIColor.separator.resolvedColor(with: traits) + value.table.headerBackgroundColor = UIColor.quaternaryLabel.resolvedColor(with: traits).withAlphaComponent(0.12) + value.table.stripeCellBackgroundColor = UIColor.quaternaryLabel.resolvedColor(with: traits).withAlphaComponent( + 0.06) + return value + } +} + +struct MobileAssistantScanning: ViewModifier { + let active: Bool + @Environment(\.mobileListRowIsDisplayed) private var displayed + @Environment(\.mobileListReduceMotion) private var reduceMotion + @Environment(\.mobileListSceneIsActive) private var sceneIsActive + + func body(content: Content) -> some View { + if active && displayed && !reduceMotion && sceneIsActive { + content.mask { + TimelineView(.animation) { context in + GeometryReader { geometry in + let width = geometry.size.width + let band = AssistantTranscriptStyle.scanBandWidth(for: width) + let phase = + context.date.timeIntervalSinceReferenceDate + .truncatingRemainder(dividingBy: AssistantTranscriptStyle.scanDuration) + / AssistantTranscriptStyle.scanDuration + ZStack(alignment: .leading) { + Color.white + LinearGradient( + colors: AssistantTranscriptStyle.scanAlphas.map { Color.white.opacity($0) }, + startPoint: .leading, endPoint: .trailing + ) + .frame(width: band) + .offset(x: -band + (width + band) * phase) + .blendMode(.destinationOut) + } + .compositingGroup() + } + } + } + } else { + content + } + } +} + +struct MobileAssistantActivity: View { + var cyclesText = false + @State private var startedAt = Date.now + @Environment(\.mobileListRowIsDisplayed) private var displayed + @Environment(\.mobileListReduceMotion) private var reduceMotion + @Environment(\.mobileListSceneIsActive) private var sceneIsActive + + var body: some View { + Group { + if cyclesText && displayed && !reduceMotion && sceneIsActive { + TimelineView(.animation(minimumInterval: AssistantTranscriptStyle.activityTextDuration)) { context in + let titles: [LocalizedStringKey] = ["Working…", "Thinking…", "Reasoning…"] + let index = + max( + 0, + Int( + context.date.timeIntervalSince(startedAt) + / AssistantTranscriptStyle.activityTextDuration)) % titles.count + Text(titles[index]) + } + } else { + Text("Working…") + } + } + .foregroundStyle(.secondary) + .modifier(MobileAssistantScanning(active: true)) + } +} + +struct MobileAssistantDisclosure: View { + let expanded: Bool + let toggle: () -> Void + @ViewBuilder var title: () -> Title + @ViewBuilder var content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Button(action: toggle) { + HStack(spacing: 8) { + title().frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10, weight: .bold)).foregroundStyle(.tertiary) + } + .frame(minHeight: 44).contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityValue(expanded ? Text("Hide details") : Text("Show details")) + if expanded { content() } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/TFI/Features/AI/MobileAssistantWorkRow.swift b/TFI/Features/AI/MobileAssistantWorkRow.swift new file mode 100644 index 00000000..1dedd367 --- /dev/null +++ b/TFI/Features/AI/MobileAssistantWorkRow.swift @@ -0,0 +1,207 @@ +import AgentKit +import SwiftUI +import TermindApplication +import TermindPresentation +import TermindRemoteInfrastructure + +struct MobileAssistantWorkRow: View { + let group: AgentWorkGroup + let presentation: AssistantTranscriptPresentationState + let hostName: (UUID) -> String + @Environment(\.locale) private var locale + @Environment(\.mobileListRowIsDisplayed) private var displayed + @Environment(\.mobileListSceneIsActive) private var sceneIsActive + + private var defaultExpanded: Bool { + AgentWorkPresentation.isExpandedByDefault(for: group.run.state, appearsAfterAnswer: group.appearsAfterAnswer) + } + + var body: some View { + VStack(spacing: 12) { + MobileAssistantDisclosure( + expanded: presentation.isExpanded(.work(group.id), default: defaultExpanded), + toggle: { presentation.toggleDisclosure(.work(group.id), default: defaultExpanded) } + ) { + if !group.run.state.isTerminal && displayed && sceneIsActive { + TimelineView(.periodic(from: .now, by: 1)) { context in workTitle(now: context.date) } + } else { + workTitle(now: group.run.finishedAt ?? .now) + } + } content: { + VStack(alignment: .leading, spacing: AssistantTranscriptStyle.blockSpacing) { + ForEach(group.items) { item in + switch item { + case .reasoning(let block): + MobileAssistantMarkdown(text: block.text, secondary: true) + .modifier(MobileAssistantScanning(active: group.activeReasoningBlockID == block.id)) + case .message(let message): + if message.text.isEmpty && message.isStreaming { + MobileAssistantActivity() + } else if !message.text.isEmpty { + MobileAssistantMarkdown(text: message.text) + } + case .tool(let card): + MobileAssistantToolRow(card: card, presentation: presentation, hostName: hostName) + case .tools(let batch): + MobileAssistantToolBatchRow(batch: batch, presentation: presentation, hostName: hostName) + } + } + } + } + Divider() + } + } + + private func workTitle(now: Date) -> some View { + Text( + AgentWorkPresentation.title( + for: group.run.state, from: group.run.startedAt, to: group.run.finishedAt ?? now, locale: locale + ) + ) + .font(.callout).foregroundStyle(.secondary).monospacedDigit() + } +} + +struct MobileAssistantToolRow: View { + @ScaledMetric(relativeTo: .callout) private var iconSize: CGFloat = 16 + let card: AgentToolCard + let presentation: AssistantTranscriptPresentationState + let hostName: (UUID) -> String + let defaultExpanded: Bool + @Environment(\.locale) private var locale + + init( + card: AgentToolCard, + presentation: AssistantTranscriptPresentationState, + hostName: @escaping (UUID) -> String, + defaultExpanded: Bool = false + ) { + self.card = card + self.presentation = presentation + self.hostName = hostName + self.defaultExpanded = defaultExpanded + } + + var body: some View { + let expanded = presentation.isExpanded(.tool(card.id), default: defaultExpanded) + MobileAssistantDisclosure(expanded: expanded, toggle: toggle) { + HStack(spacing: 8) { + Image(systemName: AgentToolPresentation.symbol(for: card)) + .foregroundStyle(AgentToolPresentation.tint(for: card.state).color) + .font(.system(size: iconSize)).frame(width: iconSize) + Text( + AgentToolPresentation.summary( + for: card, hostName: card.invocation.targetHostID.map(hostName), locale: locale + ) + ) + .foregroundStyle(card.state == .running ? Color.secondary : .primary) + .lineLimit(expanded ? nil : 1) + .modifier(MobileAssistantScanning(active: card.state == .running)) + if card.state == .reviewing { + Image(systemName: "checkmark.shield").foregroundStyle(.orange) + .accessibilityLabel(Text("Security Review in progress")) + } + } + .font(.callout).opacity(card.state == .interrupted ? 0.5 : 1) + } content: { + VStack(alignment: .leading, spacing: 8) { + if let review = card.review { + Text("Security Review: \(review.risk.displayName) — \(review.reason)") + .font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + if let detail = AgentToolPresentation.detail( + for: card, presenter: TermindAgentTools.presenter(for: card.descriptor), locale: locale + ) { + AssistantToolDetails(items: detail.items, isError: card.result?.isError == true) { + UIPasteboard.general.string = $0 + } + } + if card.result?.isTruncated == true { + Text("Output truncated").font(.caption2).foregroundStyle(.orange) + } + } + .padding(.leading, iconSize + 8) + } + } + private func toggle() { presentation.toggleDisclosure(.tool(card.id), default: defaultExpanded) } + +} + +private struct MobileAssistantToolBatchRow: View { + @ScaledMetric(relativeTo: .callout) private var iconSize: CGFloat = 16 + let batch: AgentToolBatch + let presentation: AssistantTranscriptPresentationState + let hostName: (UUID) -> String + @Environment(\.locale) private var locale + @Environment(\.mobileListRowIsDisplayed) private var displayed + @Environment(\.mobileListSceneIsActive) private var sceneIsActive + @State private var deadline: Date? + @State private var now = Date.now + + private var unfinished: [AgentToolCard] { batch.cards.filter { !$0.state.isTerminal } } + private var reasoning: AgentToolReasoning? { unfinished.isEmpty ? nil : batch.reasoning } + private var showsReasoning: Bool { reasoning != nil && deadline.map { $0 > now } == true } + + @ViewBuilder + var body: some View { + if batch.cards.count == 1, let card = batch.cards.first { + MobileAssistantToolRow( + card: card, + presentation: presentation, + hostName: hostName, + defaultExpanded: true + ) + } else { + MobileAssistantDisclosure( + expanded: presentation.isExpanded(.toolBatch(batch.id)), + toggle: { presentation.toggleDisclosure(.toolBatch(batch.id)) } + ) { + HStack(spacing: 8) { + Image(systemName: AgentToolPresentation.batchSymbol(for: batch.cards)) + .foregroundStyle(AgentToolPresentation.batchTint(for: batch.cards).color) + .font(.system(size: iconSize)).frame(width: iconSize) + Group { + if showsReasoning, let reasoning { + Text(TermindMarkdownParser.inlineTitle(reasoning.markdown)) + } else { + Text(fallbackTitle) + } + } + .lineLimit(1).foregroundStyle(.secondary) + .modifier(MobileAssistantScanning(active: !unfinished.isEmpty)) + } + .font(.callout) + } content: { + VStack(alignment: .leading, spacing: AssistantTranscriptStyle.blockSpacing) { + ForEach(batch.cards) { card in + MobileAssistantToolRow(card: card, presentation: presentation, hostName: hostName) + } + } + } + .task(id: TitleUpdate(revision: reasoning?.revision, displayed: displayed, sceneIsActive: sceneIsActive)) { + guard displayed, sceneIsActive, let reasoning else { return } + now = .now + let expires = presentation.reasoningTitleDeadline(for: batch.id, revision: reasoning.revision) + deadline = expires + guard expires > now else { return } + do { + try await Task.sleep(for: .seconds(expires.timeIntervalSinceNow)) + now = .now + } catch {} + } + } + } + + private struct TitleUpdate: Equatable { + var revision: String? + var displayed: Bool + var sceneIsActive: Bool + } + + private var fallbackTitle: String { + if unfinished.isEmpty { return AgentToolPresentation.batchSummary(for: batch.cards, locale: locale) } + return unfinished.map { + AgentToolPresentation.summary(for: $0, hostName: $0.invocation.targetHostID.map(hostName), locale: locale) + }.joined(separator: "; ") + } +} diff --git a/TFI/Features/AI/MobileSlashCommandPanel.swift b/TFI/Features/AI/MobileSlashCommandPanel.swift index dc054335..45831752 100644 --- a/TFI/Features/AI/MobileSlashCommandPanel.swift +++ b/TFI/Features/AI/MobileSlashCommandPanel.swift @@ -14,29 +14,22 @@ struct MobileSlashCommandPanel: View { var hasOptions: (ChatSlashCommand) -> Bool var trailingText: (ChatSlashCommandCompletion.Row) -> String? var isCurrent: (ChatSlashCommandCompletion.Row) -> Bool - var onBack: () -> Void - var onClose: () -> Void var onSelect: (ChatSlashCommandCompletion.Row) -> Void - @ScaledMetric(relativeTo: .body) private var rowHeight = 88.0 - @ScaledMetric(relativeTo: .callout) private var headerHeight = 56.0 + @ScaledMetric(relativeTo: .body) private var rowHeight = 64.0 var body: some View { - VStack(spacing: 0) { - header - Divider() - ScrollView { - LazyVStack(spacing: 0) { - ForEach(Array(completion.rows.enumerated()), id: \.element.id) { index, row in - rowButton(row) - if index < completion.rows.count - 1 { - Divider().padding(.leading, 52) - } + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(completion.rows.enumerated()), id: \.element.id) { index, row in + rowButton(row) + if index < completion.rows.count - 1 { + Divider().padding(.leading, 52) } } } - .scrollIndicators(.visible) } + .scrollIndicators(.visible) .frame(height: panelHeight) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .overlay { @@ -48,55 +41,15 @@ struct MobileSlashCommandPanel: View { .accessibilityLabel(Text("Commands")) } - private var optionCommand: ChatSlashCommand? { - guard case .option(let command, _) = completion.rows.first else { return nil } - return command - } - private var panelHeight: CGFloat { - min(440, headerHeight + rowHeight * CGFloat(completion.rows.count)) - } - - private var header: some View { - HStack(spacing: 8) { - if let optionCommand { - Button(action: onBack) { - Label("Back", systemImage: "chevron.left") - .labelStyle(.iconOnly) - .foregroundStyle(.secondary) - .frame(width: 48, height: 48) - .contentShape(.rect) - } - .accessibilityLabel(Text("Back")) - - Text(optionCommand.displayName) - .font(.headline) - .lineLimit(1) - } else { - Label("Commands", systemImage: "slash.circle") - .font(.headline) - } - - Spacer(minLength: 8) - - Button(action: onClose) { - Label("Close", systemImage: "xmark") - .labelStyle(.iconOnly) - .foregroundStyle(.secondary) - .frame(width: 48, height: 48) - .contentShape(.rect) - } - .accessibilityLabel(Text("Close")) - } - .padding(.horizontal, 8) - .frame(minHeight: headerHeight) + rowHeight * CGFloat(min(completion.rows.count, 3)) } private func rowButton(_ row: ChatSlashCommandCompletion.Row) -> some View { Button { onSelect(row) } label: { - HStack(alignment: .top, spacing: 12) { + HStack(alignment: .center, spacing: 12) { commandIcon(for: row) .frame(width: 28, height: 28) @@ -139,7 +92,7 @@ struct MobileSlashCommandPanel: View { .font(.subheadline).foregroundStyle(Color.primary.opacity(0.62)) .lineLimit(1).truncationMode(.tail) } - .frame(maxWidth: .infinity, minHeight: 54, maxHeight: 54, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) case .option(_, let option): VStack(alignment: .leading, spacing: 5) { Text(verbatim: option.label).font(.callout.weight(.medium)).lineLimit(1) @@ -147,7 +100,7 @@ struct MobileSlashCommandPanel: View { .font(.subheadline).foregroundStyle(Color.primary.opacity(0.62)) .lineLimit(1).truncationMode(.tail) } - .frame(maxWidth: .infinity, minHeight: 54, maxHeight: 54, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/TFI/Features/Assistant/MobileAssistantView.swift b/TFI/Features/Assistant/MobileAssistantView.swift index 52168902..cc9bca45 100644 --- a/TFI/Features/Assistant/MobileAssistantView.swift +++ b/TFI/Features/Assistant/MobileAssistantView.swift @@ -3,14 +3,31 @@ import SwiftUI import TermindApplication import TermindDomain +enum MobileAssistantLibraryRoute: String, Identifiable, Hashable { + case conversations + case archived + + var id: String { rawValue } + + var title: LocalizedStringKey { + switch self { + case .conversations: "Conversations" + case .archived: "Archived" + } + } +} + struct MobileAssistantView: View { let app: MobileAppModel @Bindable var model: MobileAssistantModel - @State private var history = false - @State private var archive = false - @State private var renaming = false + @State private var libraryRoute: MobileAssistantLibraryRoute? + @State private var restoreComposerFocusAfterLibrary = false + @State private var nextComposerFocusRequest = 0 + @State private var composerFocusRequest: Int? + @State private var renaming: ConversationModel? @State private var title = "" - @State private var deleting = false + @State private var deleting: ConversationModel? + @FocusState private var composerFocused: Bool var body: some View { detail @@ -20,46 +37,72 @@ struct MobileAssistantView: View { .navigationBarTitleDisplayMode(.inline) .toolbarTitleMenu { conversationTitleMenu } .toolbar { - Button("New Conversation", systemImage: "square.and.pencil") { model.newConversation() }.labelStyle( - .iconOnly) + Button("New Conversation", systemImage: "plus.bubble", action: newConversation) + .labelStyle(.iconOnly) Menu { + Button("Conversations", systemImage: "clock") { showLibrary(.conversations) } + Button("Archived", systemImage: "archivebox") { showLibrary(.archived) } + Divider() Button("Rename", systemImage: "pencil") { title = model.conversation.derivedTitle - renaming = true + renaming = model.conversation } .disabled(model.conversation.isEmpty) Button(model.conversation.isPinned ? "Unpin" : "Pin", systemImage: "pin") { - Task { await model.move(model.conversation, pinned: !model.conversation.isPinned) } + let conversation = model.conversation + Task { await model.move(conversation, pinned: !conversation.isPinned) } } .disabled(model.conversation.isEmpty || model.conversation.isArchived) Button( model.conversation.isArchived ? "Unarchive" : "Archive", - systemImage: model.conversation.isArchived ? "tray.and.arrow.up" : "archivebox" + systemImage: model.conversation.isArchived ? "arrow.uturn.backward" : "archivebox" ) { - Task { await model.archive(model.conversation, archived: !model.conversation.isArchived) } + let conversation = model.conversation + Task { await model.archive(conversation, archived: !conversation.isArchived) } + } + .disabled(!canChangeHistory) + Button("Delete", systemImage: "trash", role: .destructive) { + deleting = model.conversation } .disabled(!canChangeHistory) - Button("Delete", systemImage: "trash", role: .destructive) { deleting = true } - .disabled(!canChangeHistory) } label: { MobileOverflowMenuLabel("More") } } - .navigationDestination(isPresented: $history) { - MobileAssistantLibrary(model: model) { history = false } - .navigationTitle("Conversations") + .navigationDestination(item: $libraryRoute) { route in + MobileAssistantLibrary( + model: model, + route: route, + selected: { libraryRoute = nil }, + showArchived: { libraryRoute = .archived } + ) + .navigationTitle(route.title) } - .navigationDestination(isPresented: $archive) { - MobileAssistantLibrary(model: model, archived: true) { archive = false } - .navigationTitle("Archived") + .onChange(of: libraryRoute) { previous, current in + guard previous != nil, current == nil else { return } + requestComposerFocusIfNeeded(restoreComposerFocusAfterLibrary) + restoreComposerFocusAfterLibrary = false } - .alert("Rename", isPresented: $renaming) { + .alert( + "Rename", + isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } }) + ) { TextField("Conversation Name", text: $title) - Button("Cancel", role: .cancel) {} - Button("Save") { Task { await model.rename(model.conversation, title: title) } } + Button("Cancel", role: .cancel) { renaming = nil } + Button("Save") { + if let renaming { Task { await model.rename(renaming, title: title) } } + renaming = nil + } } - .confirmationDialog("Delete Conversation", isPresented: $deleting, titleVisibility: .visible) { - Button("Delete", role: .destructive) { Task { await model.delete(model.conversation) } } + .confirmationDialog( + "Delete Conversation", + isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let deleting { Task { await model.delete(deleting) } } + deleting = nil + } } message: { Text("Permanently delete this conversation and its staged files?") } @@ -75,14 +118,18 @@ struct MobileAssistantView: View { } private var detail: some View { - MobileAssistantConversation(app: app, model: model, conversation: model.conversation) - .id(model.conversation.id) + MobileAssistantConversation( + app: app, + model: model, + conversation: model.conversation, + composerFocus: $composerFocused, + composerFocusRequest: composerFocusRequest, + composerFocusRequestHandled: composerFocusRequestHandled + ) + .id(model.conversation.id) } @ViewBuilder private var conversationTitleMenu: some View { - Button("New Conversation", systemImage: model.conversation.isEmpty ? "checkmark" : "square.and.pencil") { - model.newConversation() - } if !model.library.pinnedConversations.isEmpty { Section("Pinned") { ForEach(model.library.pinnedConversations) { conversation in titleMenuButton(conversation) } @@ -93,9 +140,6 @@ struct MobileAssistantView: View { ForEach(model.library.activeConversations) { conversation in titleMenuButton(conversation) } } } - Divider() - Button("Conversations", systemImage: "clock") { history = true } - Button("Archived", systemImage: "archivebox") { archive = true } } private func titleMenuButton(_ conversation: ConversationModel) -> some View { @@ -105,14 +149,51 @@ struct MobileAssistantView: View { Label( conversation.derivedTitle, systemImage: model.conversation.id == conversation.id - ? "checkmark" : (conversation.isStreaming ? "ellipsis.circle" : "bubble.left") + ? "checkmark" : (conversation.isStreaming ? "ellipsis.circle" : "sparkles") ) } } + private func newConversation() { + libraryRoute = nil + replaceConversation(preservingComposerFocus: model.newConversation) + } + private func select(_ conversation: ConversationModel) { guard model.conversation.id != conversation.id else { return } - model.select(conversation) + replaceConversation { model.select(conversation) } + } + + private func showLibrary(_ route: MobileAssistantLibraryRoute) { + if libraryRoute == nil { restoreComposerFocusAfterLibrary = composerFocused } + libraryRoute = route + } + + private func replaceConversation(preservingComposerFocus action: () -> Void) { + let shouldRestoreFocus = composerFocused + action() + requestComposerFocusIfNeeded(shouldRestoreFocus) + } + + private func requestComposerFocusIfNeeded(_ requested: Bool) { + guard requested, canFocusComposer else { return } + let conversationID = model.conversation.id + Task { @MainActor in + await Task.yield() + guard model.conversation.id == conversationID, canFocusComposer else { return } + nextComposerFocusRequest += 1 + composerFocusRequest = nextComposerFocusRequest + } + } + + private func composerFocusRequestHandled(_ request: Int) { + if composerFocusRequest == request { composerFocusRequest = nil } + } + + private var canFocusComposer: Bool { + !model.conversation.isArchived + && model.conversation.pendingApproval == nil + && model.conversation.pendingUserInteraction == nil } private var canChangeHistory: Bool { @@ -123,13 +204,15 @@ struct MobileAssistantView: View { struct MobileAssistantLibrary: View { @Bindable var model: MobileAssistantModel - var archived = false + let route: MobileAssistantLibraryRoute let selected: () -> Void + let showArchived: () -> Void @State private var query = "" @State private var renaming: ConversationModel? @State private var title = "" @State private var deleting: ConversationModel? - @State private var showingArchive = false + + private var archived: Bool { route == .archived } var body: some View { List { @@ -139,13 +222,13 @@ struct MobileAssistantLibrary: View { model.newConversation() selected() } label: { - Label("New Conversation", systemImage: "square.and.pencil") - .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) + Label("New Conversation", systemImage: "plus.bubble") + .frame(maxWidth: .infinity, alignment: .leading) } .foregroundStyle(.primary) Button { - showingArchive = true + showArchived() } label: { HStack { Label("Archived", systemImage: "archivebox") @@ -157,7 +240,6 @@ struct MobileAssistantLibrary: View { .foregroundStyle(.tertiary) .accessibilityHidden(true) } - .frame(minHeight: 48) } .foregroundStyle(.primary) } @@ -169,8 +251,10 @@ struct MobileAssistantLibrary: View { } } - if !ordinaryConversations.isEmpty { - Section(archived ? "" : String(localized: "Conversations")) { + if archived { + ForEach(ordinaryConversations) { conversationRow($0) } + } else if !ordinaryConversations.isEmpty { + Section(String(localized: "Conversations")) { ForEach(ordinaryConversations) { conversationRow($0) } } } @@ -178,17 +262,14 @@ struct MobileAssistantLibrary: View { if values.isEmpty { ContentUnavailableView( archived ? "No Archived Chats" : "No Conversations", - systemImage: archived ? "archivebox" : "bubble.left" + systemImage: archived ? "archivebox" : "sparkles" ) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } } + .navigationBarTitleDisplayMode(.inline) .searchable(text: $query, prompt: "Search") - .navigationDestination(isPresented: $showingArchive) { - MobileAssistantLibrary(model: model, archived: true, selected: selected) - .navigationTitle("Archived") - } .alert("Rename", isPresented: Binding(get: { renaming != nil }, set: { if !$0 { renaming = nil } })) { TextField("Conversation Name", text: $title) Button("Cancel", role: .cancel) { renaming = nil } @@ -218,29 +299,29 @@ struct MobileAssistantLibrary: View { Button { select(conversation) } label: { - HStack(alignment: .center, spacing: 12) { - Image(systemName: conversation.isArchived ? "archivebox" : "bubble.left") + HStack(spacing: 12) { + Image(systemName: conversation.isArchived ? "archivebox" : "sparkles") .foregroundStyle(.secondary) .frame(width: 28, height: 28) .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 5) { - Text(conversation.derivedTitle).font(.headline).foregroundStyle(.primary).lineLimit(2) + VStack(alignment: .leading, spacing: 2) { + Text(conversation.derivedTitle).foregroundStyle(.primary).lineLimit(1) if conversation.isStreaming { Text("Working…").font(.caption).foregroundStyle(.secondary) } } - Spacer(minLength: 8) + Spacer(minLength: 0) if model.conversation.id == conversation.id { Image(systemName: "checkmark").foregroundStyle(.tint).accessibilityHidden(true) } } - .frame(maxWidth: .infinity, minHeight: 60, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) .contentShape(.rect) } .buttonStyle(.plain) .swipeActions(edge: .trailing, allowsFullSwipe: true) { if conversation.isArchived { - Button("Unarchive", systemImage: "tray.and.arrow.up") { + Button("Unarchive", systemImage: "arrow.uturn.backward") { Task { await model.archive(conversation, archived: false) } } .tint(.accentColor) @@ -274,7 +355,7 @@ struct MobileAssistantLibrary: View { } Button( conversation.isArchived ? "Unarchive" : "Archive", - systemImage: conversation.isArchived ? "tray.and.arrow.up" : "archivebox" + systemImage: conversation.isArchived ? "arrow.uturn.backward" : "archivebox" ) { Task { await model.archive(conversation, archived: !conversation.isArchived) } }.disabled(conversation.isStreaming || conversation.isCompacting || conversation.isHistoryChanging) diff --git a/TFI/Features/Terminal/MobileTerminalKeyboard.swift b/TFI/Features/Terminal/MobileTerminalKeyboard.swift index 2e6bef8f..a1cf9438 100644 --- a/TFI/Features/Terminal/MobileTerminalKeyboard.swift +++ b/TFI/Features/Terminal/MobileTerminalKeyboard.swift @@ -36,6 +36,9 @@ struct MobileTerminalKeyboard: View { key("End", systemImage: "arrow.down.to.line.compact", .end) key("Page Up", systemImage: "arrowtriangle.up.square", .pageUp) key("Page Down", systemImage: "arrowtriangle.down.square", .pageDown) + ForEach(MobileTerminalControlShortcut.allCases) { shortcut in + controlShortcut(shortcut) + } } .padding(.leading, 8) } @@ -160,6 +163,41 @@ struct MobileTerminalKeyboard: View { .contentShape(.rect) } } + + private func controlShortcut(_ shortcut: MobileTerminalControlShortcut) -> some View { + Button { + _ = state.attachedPlatformView?.sendKey(shortcut.keyPress) + } label: { + Text(verbatim: shortcut.label) + .frame(width: 44, height: 44) + .contentShape(.rect) + } + .accessibilityLabel(Text("Control") + Text(verbatim: " \(shortcut.letter)")) + } +} + +nonisolated enum MobileTerminalControlShortcut: String, CaseIterable, Identifiable { + case c = "C" + case i = "I" + case s = "S" + case z = "Z" + + var id: Self { self } + var letter: String { rawValue } + var label: String { "^\(letter)" } + + var keyPress: TerminalKeyPress { + TerminalKeyPress(key, modifiers: .ctrl) + } + + private var key: TerminalKey { + switch self { + case .c: .c + case .i: .i + case .s: .s + case .z: .z + } + } } nonisolated struct MobileTerminalShiftState: Equatable { diff --git a/TFI/Features/Workspaces/MobileWorkspaceView.swift b/TFI/Features/Workspaces/MobileWorkspaceView.swift index 0f0612c9..a04ffdbe 100644 --- a/TFI/Features/Workspaces/MobileWorkspaceView.swift +++ b/TFI/Features/Workspaces/MobileWorkspaceView.swift @@ -42,6 +42,17 @@ struct MobileWorkspaceView: View { var body: some View { workspaceAlerts + .overlay { + if uploadDropTargeted { + DropTargetOverlay(presentation: uploadDropPresentation) + } + } + .contentShape(.rect) + .onDrop( + of: [.fileURL], + isTargeted: $uploadDropTargeted, + perform: receiveUploadProviders + ) } private var workspaceChrome: some View { @@ -58,21 +69,6 @@ struct MobileWorkspaceView: View { .padding() .allowsHitTesting(false) } - .overlay { - if uploadDropTargeted { - MobileUploadDropOverlay( - title: uploadDropTitle, - detail: uploadDropDetail, - available: workspace.uploadTargetProblem == nil - ) - .allowsHitTesting(false) - } - } - .dropDestination(for: URL.self) { urls, _ in - receiveUpload(urls) - } isTargeted: { targeted in - uploadDropTargeted = targeted - } .navigationTitle(workspace.descriptor.title) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(isSFTP) @@ -148,6 +144,15 @@ struct MobileWorkspaceView: View { Button("Paste", systemImage: "doc.on.clipboard", action: pasteRemoteItems) .disabled(!workspace.connected) sortMenu + if workspace.sftpSession.showsHiddenFiles { + Button("Hide Hidden Files", systemImage: "eye.slash") { + workspace.sftpSession.showsHiddenFiles = false + } + } else { + Button("Show Hidden Files", systemImage: "eye") { + workspace.sftpSession.showsHiddenFiles = true + } + } Button("Refresh", systemImage: "arrow.clockwise") { Task { await workspace.sftpSession.reload() } } @@ -358,18 +363,22 @@ struct MobileWorkspaceView: View { : String(localized: "\(workspace.transferConflictCount) items already exist") } - private var uploadDropTitle: String { - if workspace.uploadTargetProblem != nil { - return String(localized: "Can't Upload Here") + private var uploadDropPresentation: DropTargetPresentation { + if let problem = workspace.uploadTargetProblem { + return DropTargetPresentation( + title: String(localized: "Can't Upload Here"), + detail: problem, + systemImage: "exclamationmark.triangle.fill", + tone: .unavailable + ) } let hostName = library.host(workspace.descriptor.hostID)?.name ?? workspace.descriptor.title - return String(localized: "Upload to \(hostName)") - } - - private var uploadDropDetail: String { - workspace.uploadTargetProblem - ?? workspace.uploadDestination - ?? String(localized: "The upload destination is unavailable.") + return DropTargetPresentation( + title: String(localized: "Upload to \(hostName)"), + detail: workspace.uploadDestination + ?? String(localized: "The upload destination is unavailable."), + systemImage: "arrow.up.doc.fill" + ) } private func receiveUpload(_ urls: [URL]) -> Bool { @@ -381,6 +390,27 @@ struct MobileWorkspaceView: View { return workspace.prepareUpload(urls) } + private func receiveUploadProviders(_ providers: [NSItemProvider]) -> Bool { + let kinds: Set = [.files] + guard MobileDropPayloadLoader.accepts(providers, kinds: kinds) else { return false } + Task { + let payload = await MobileDropPayloadLoader.load( + providers, + kinds: kinds, + fileMode: .staged + ) + if !payload.fileURLs.isEmpty { + let accepted = receiveUpload(payload.fileURLs) + if !accepted, let stagedRoot = payload.stagedRoot { + try? FileManager.default.removeItem(at: stagedRoot) + } + } else if let error = payload.failures.first { + workspace.operationError = error + } + } + return true + } + private func enqueuePendingUpload(_ policy: TransferConflictPolicy) { workspace.enqueuePendingTransfer(policy) uploadConflictPresented = false @@ -605,30 +635,6 @@ private struct MobileUploadNoticeOverlay: View { } } -private struct MobileUploadDropOverlay: View { - let title: String - let detail: String - let available: Bool - - var body: some View { - VStack(spacing: 10) { - Image(systemName: available ? "arrow.up.doc.fill" : "exclamationmark.triangle.fill") - .font(.largeTitle) - .foregroundStyle(available ? Color.accentColor : .orange) - Text(title).font(.headline) - Text(detail) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - .truncationMode(.middle) - } - .multilineTextAlignment(.center) - .padding(24) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.regularMaterial) - } -} - private struct MobileTransientOverlay: View { @ViewBuilder let content: Content diff --git a/TFI/Platform/IOSSpeechAudioInput.swift b/TFI/Platform/IOSSpeechAudioInput.swift index e1298773..380ed01b 100644 --- a/TFI/Platform/IOSSpeechAudioInput.swift +++ b/TFI/Platform/IOSSpeechAudioInput.swift @@ -30,14 +30,10 @@ final class IOSSpeechAudioInput: SpeechAudioInput { stop() throw SpeechTranscriptionFailure(.audioInputUnavailable) } - input.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in - bufferHandler(buffer) - if let values = buffer.floatChannelData?[0], buffer.frameLength > 0 { - var square: Float = 0 - for i in 0.. AVAudioNodeTapBlock { + { buffer, _ in + bufferHandler(buffer) + if let values = buffer.floatChannelData?[0], buffer.frameLength > 0 { + var square: Float = 0 + for i in 0.. + ) -> Bool { + providers.contains { provider in + if kinds.contains(.files), + provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) + { + return true + } + return kinds.contains(.images) + && provider.registeredTypeIdentifiers.contains(where: isImageType) + } + } + + static func load( + _ providers: [NSItemProvider], + kinds: Set, + fileMode: MobileDropFileMode = .referenced + ) async -> MobileDropPayload { + var payload = MobileDropPayload() + if fileMode == .staged { + do { + payload.stagedRoot = try makeStagingRoot() + } catch { + payload.failures.append(error.localizedDescription) + return payload + } + } + for provider in providers { + do { + if kinds.contains(.files), + provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) + { + guard + let url = try await fileURL( + from: provider, + stagingRoot: payload.stagedRoot + ), url.isFileURL + else { + throw CocoaError(.fileReadUnknown) + } + payload.fileURLs.append(url) + } else if kinds.contains(.images), + let identifier = provider.registeredTypeIdentifiers.first(where: isImageType) + { + payload.images.append(try await data(from: provider, typeIdentifier: identifier)) + } + } catch { + payload.failures.append(error.localizedDescription) + } + } + if payload.fileURLs.isEmpty, let stagedRoot = payload.stagedRoot { + try? FileManager.default.removeItem(at: stagedRoot) + payload.stagedRoot = nil + } + return payload + } + + private static func isImageType(_ identifier: String) -> Bool { + UTType(identifier)?.conforms(to: .image) == true + } + + private static func fileURL( + from provider: NSItemProvider, + stagingRoot: URL? + ) async throws -> URL? { + try await withCheckedThrowingContinuation { continuation in + provider.loadObject(ofClass: NSURL.self) { item, error in + if let error { + continuation.resume(throwing: error) + return + } + if let url = item as? NSURL { + do { + continuation.resume( + returning: try stagingRoot.map { + try stage(url as URL, in: $0) + } ?? (url as URL) + ) + } catch { + continuation.resume(throwing: error) + } + return + } + continuation.resume(returning: nil) + } + } + } + + private static func makeStagingRoot() throws -> URL { + let parent = FileManager.default.temporaryDirectory.appending( + path: "TermindDropUploads", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60) + for candidate in try FileManager.default.contentsOfDirectory( + at: parent, + includingPropertiesForKeys: [.contentModificationDateKey], + options: [.skipsHiddenFiles] + ) { + let modified = try? candidate.resourceValues( + forKeys: [.contentModificationDateKey] + ).contentModificationDate + if modified.map({ $0 < expiration }) == true { + try? FileManager.default.removeItem(at: candidate) + } + } + let root = parent.appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + nonisolated private static func stage(_ source: URL, in root: URL) throws -> URL { + let itemRoot = root.appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: itemRoot, withIntermediateDirectories: true) + let destination = itemRoot.appending(path: source.lastPathComponent) + let accessed = source.startAccessingSecurityScopedResource() + defer { if accessed { source.stopAccessingSecurityScopedResource() } } + try FileManager.default.copyItem(at: source, to: destination) + return destination + } + + private static func data( + from provider: NSItemProvider, + typeIdentifier: String + ) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + provider.loadDataRepresentation(forTypeIdentifier: typeIdentifier) { data, error in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? CocoaError(.fileReadUnknown)) + } + } + } + } +} diff --git a/TFI/UI/Components/MobileNativeList.swift b/TFI/UI/Components/MobileNativeList.swift index 3584ce09..4e2345be 100644 --- a/TFI/UI/Components/MobileNativeList.swift +++ b/TFI/UI/Components/MobileNativeList.swift @@ -2,6 +2,53 @@ import ListViewKit import SwiftUI import UIKit +private struct MobileListRowIsDisplayedKey: EnvironmentKey { + static let defaultValue = true +} + +private struct MobileListReduceMotionKey: EnvironmentKey { + static let defaultValue = false +} + +private struct MobileListSceneIsActiveKey: EnvironmentKey { + static let defaultValue = true +} + +extension EnvironmentValues { + var mobileListReduceMotion: Bool { + get { self[MobileListReduceMotionKey.self] } + set { self[MobileListReduceMotionKey.self] = newValue } + } + var mobileListSceneIsActive: Bool { + get { self[MobileListSceneIsActiveKey.self] } + set { self[MobileListSceneIsActiveKey.self] = newValue } + } + + var mobileListRowIsDisplayed: Bool { + get { self[MobileListRowIsDisplayedKey.self] } + set { self[MobileListRowIsDisplayedKey.self] = newValue } + } +} + +enum MobileNativeListSurface: Hashable { + case grouped + case plain + + var listBackground: UIColor { + switch self { + case .grouped: .systemGroupedBackground + case .plain: .systemBackground + } + } + + var rowBackground: UIColor { + switch self { + case .grouped: .secondarySystemGroupedBackground + case .plain: .systemBackground + } + } +} + struct MobileNativeList: UIViewRepresentable where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { typealias UIViewType = ListView @@ -12,17 +59,30 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { let onSelect: ((Item) -> Void)? let onRefresh: (() async -> Void)? let followsBottom: Bool + let surface: MobileNativeListSurface + let rowRevision: ((Item) -> UInt64)? + let prepareUpdate: (([Item], [Item], Set) -> Void)? + let onViewportChange: ((Set) -> Void)? + let onTap: (() -> Void)? @ViewBuilder let rowContent: (Item) -> RowContent @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.locale) private var locale + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.mobileListReduceMotion) private var reduceMotionOverride + @Environment(\.scenePhase) private var scenePhase init( items: [Item], estimatedRowHeight: CGFloat = 72, refreshID: AnyHashable = 0, onSelect: ((Item) -> Void)? = nil, onRefresh: (() async -> Void)? = nil, followsBottom: Bool = false, + surface: MobileNativeListSurface = .grouped, + rowRevision: ((Item) -> UInt64)? = nil, + prepareUpdate: (([Item], [Item], Set) -> Void)? = nil, + onViewportChange: ((Set) -> Void)? = nil, + onTap: (() -> Void)? = nil, @ViewBuilder rowContent: @escaping (Item) -> RowContent ) { self.items = items @@ -31,6 +91,11 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { self.onSelect = onSelect self.onRefresh = onRefresh self.followsBottom = followsBottom + self.surface = surface + self.rowRevision = rowRevision + self.prepareUpdate = prepareUpdate + self.onViewportChange = onViewportChange + self.onTap = onTap self.rowContent = rowContent } @@ -39,7 +104,7 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { func makeUIView(context: Context) -> ListView { let listView = ListView() let coordinator = context.coordinator - listView.backgroundColor = .systemGroupedBackground + listView.backgroundColor = surface.listBackground listView.estimatedRowHeight = estimatedRowHeight listView.keyboardDismissMode = .interactive listView.rows { @@ -47,12 +112,14 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { .estimatedHeight(estimatedRowHeight) .configure { row, item, rowContext in if rowContext.purpose == .display { - coordinator.configure(row, item: item) + coordinator.configure(row, item: item, surface: surface) } else { - coordinator.configurePrototype(row, item: item) + coordinator.configurePrototype(row, item: item, surface: surface) } } } + coordinator.installTapRecognizer(on: listView) + coordinator.observeViewport(of: listView) return listView } @@ -64,19 +131,27 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { onSelect: onSelect, onRefresh: onRefresh, followsBottom: followsBottom, + surface: surface, refreshID: refreshID, locale: locale, colorScheme: colorScheme, - dynamicTypeSize: dynamicTypeSize + dynamicTypeSize: dynamicTypeSize, + reduceMotion: reduceMotion || reduceMotionOverride, + sceneIsActive: scenePhase == .active, + rowRevision: rowRevision, + prepareUpdate: prepareUpdate, + onViewportChange: onViewportChange, + onTap: onTap ) } static func dismantleUIView(_: ListView, coordinator: Coordinator) { coordinator.cancelRefresh() + coordinator.stopObservingViewport() } @MainActor - final class Coordinator: NSObject { + final class Coordinator: NSObject, UIGestureRecognizerDelegate { typealias Renderer = @MainActor (Item) -> AnyView private var items: [Item] = [] @@ -89,6 +164,55 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { private var colorScheme = ColorScheme.light private var dynamicTypeSize = DynamicTypeSize.large private var refreshID: AnyHashable = 0 + private var reduceMotion = false + private var sceneIsActive = true + private var revisions: [Item.ID: UInt64] = [:] + private var onViewportChange: ((Set) -> Void)? + private var onTap: (() -> Void)? + private weak var listView: ListView? + private var observations: [NSKeyValueObservation] = [] + private var viewportCheckScheduled = false + + func observeViewport(of view: ListView) { + listView = view + observations = [ + view.observe(\.bounds) { [weak self] _, _ in + MainActor.assumeIsolated { self?.scheduleViewportCheck() } + }, + view.observe(\.contentSize) { [weak self] _, _ in + MainActor.assumeIsolated { self?.scheduleViewportCheck() } + }, + ] + } + + func installTapRecognizer(on view: ListView) { + let tap = UITapGestureRecognizer(target: self, action: #selector(listTapped)) + tap.cancelsTouchesInView = false + tap.delegate = self + view.addGestureRecognizer(tap) + } + + func stopObservingViewport() { + observations.removeAll() + onViewportChange = nil + onTap = nil + listView = nil + } + + private func visibleIDs(in view: ListView) -> Set { + Set(view.indicesForVisibleRows.compactMap { items.indices.contains($0) ? items[$0].id : nil }) + } + + private func scheduleViewportCheck() { + guard !viewportCheckScheduled else { return } + viewportCheckScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + viewportCheckScheduled = false + guard let view = listView else { return } + onViewportChange?(visibleIDs(in: view)) + } + } @_optimize(none) deinit {} @@ -97,37 +221,69 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { onSelect: ((Item) -> Void)?, onRefresh: (() async -> Void)?, followsBottom: Bool, + surface: MobileNativeListSurface, refreshID newRefreshID: AnyHashable, - locale: Locale, colorScheme: ColorScheme, dynamicTypeSize: DynamicTypeSize + locale: Locale, colorScheme: ColorScheme, dynamicTypeSize: DynamicTypeSize, + reduceMotion: Bool = false, + sceneIsActive: Bool = true, + rowRevision: ((Item) -> UInt64)? = nil, + prepareUpdate: (([Item], [Item], Set) -> Void)? = nil, + onViewportChange: ((Set) -> Void)? = nil, + onTap: (() -> Void)? = nil ) { - let wasAtBottom = - items.isEmpty - || listView.contentSize.height - listView.bounds.height - - listView.contentOffset.y <= 48 + let wasEmpty = items.isEmpty + let wasAtBottom = wasEmpty || listView.isScrolledToBottom(tolerance: 48) let environmentChanged = self.locale != locale || self.colorScheme != colorScheme || self.dynamicTypeSize != dynamicTypeSize || refreshID != newRefreshID + || self.reduceMotion != reduceMotion || self.sceneIsActive != sceneIsActive self.renderer = renderer self.onSelect = onSelect self.onRefresh = onRefresh + self.onViewportChange = onViewportChange + self.onTap = onTap self.locale = locale self.colorScheme = colorScheme self.dynamicTypeSize = dynamicTypeSize + self.reduceMotion = reduceMotion + self.sceneIsActive = sceneIsActive refreshID = newRefreshID + listView.backgroundColor = surface.listBackground updateRefreshControl(in: listView) + prepareUpdate?(items, newItems, visibleIDs(in: listView)) + let newRevisions = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, rowRevision?($0) ?? 0) }) + let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + let changed = newItems.filter { + previous[$0.id] != $0 || revisions[$0.id] != newRevisions[$0.id] + } let identitiesChanged = items.map(\.id) != newItems.map(\.id) if identitiesChanged { - listView.apply(newItems, animated: !items.isEmpty) - } else { - for (old, new) in zip(items, newItems) where old != new { - listView.update(new) + if !wasEmpty, newItems.count >= items.count, + newItems.prefix(items.count).map(\.id) == items.map(\.id) + { + listView.append(contentsOf: Array(newItems.dropFirst(items.count))) + } else { + listView.apply(newItems, animated: !wasEmpty && !reduceMotion) } - if environmentChanged { listView.reloadData() } } + // A disclosure can change geometry without changing the item value. + for item in changed where previous[item.id] != nil { + if !listView.update(item), let row = listView.rowView(for: item.id) as? MobileHostingListRow { + configure(row, item: item, surface: surface) + } + listView.invalidateLayout(forRowWith: item.id) + } + if environmentChanged { listView.reloadData() } items = newItems - if followsBottom, wasAtBottom { - Task { @MainActor in listView.scrollToBottom(animated: !self.items.isEmpty) } + revisions = newRevisions + scheduleViewportCheck() + if followsBottom, wasAtBottom, identitiesChanged || !changed.isEmpty { + DispatchQueue.main.async { [weak listView] in + guard let listView, !listView.isUserInteractingWithScroll else { return } + listView.layoutIfNeeded() + listView.scrollToBottom(animated: !wasEmpty && !reduceMotion) + } } } @@ -137,6 +293,15 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { refreshControl?.endRefreshing() } + func gestureRecognizer( + _: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer + ) -> Bool { + true + } + + @objc func listTapped() { onTap?() } + private func updateRefreshControl(in listView: ListView) { if onRefresh == nil { cancelRefresh() @@ -164,24 +329,29 @@ where Item: Identifiable & Hashable & SendableMetatype, RowContent: View { } } - func configure(_ row: MobileHostingListRow, item: Item) { + func configure(_ row: MobileHostingListRow, item: Item, surface: MobileNativeListSurface) { row.configure( rootView: rootView(for: item), - onSelect: onSelect.map { action in { action(item) } } + onSelect: onSelect.map { action in { action(item) } }, + backgroundColor: surface.rowBackground ) } - func configurePrototype(_ row: MobileHostingListRow, item: Item) { - row.configure(rootView: rootView(for: item), onSelect: nil) + func configurePrototype(_ row: MobileHostingListRow, item: Item, surface: MobileNativeListSurface) { + row.configure( + rootView: rootView(for: item, displayed: false), onSelect: nil, backgroundColor: surface.rowBackground) } - private func rootView(for item: Item) -> AnyView { + private func rootView(for item: Item, displayed: Bool = true) -> AnyView { guard let renderer else { return AnyView(EmptyView()) } return AnyView( renderer(item) .environment(\.locale, locale) .environment(\.colorScheme, colorScheme) .environment(\.dynamicTypeSize, dynamicTypeSize) + .environment(\.mobileListReduceMotion, reduceMotion) + .environment(\.mobileListSceneIsActive, sceneIsActive) + .environment(\.mobileListRowIsDisplayed, displayed) .id(item.id) ) } @@ -194,13 +364,14 @@ final class MobileHostingListRow: ListRowView, UIGestureRecognizerDelegate { private var hostedView: UIView? private var onSelect: (() -> Void)? private var pressOrigin: CGPoint? + private var restingBackgroundColor = UIColor.clear override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = true - backgroundColor = .secondarySystemGroupedBackground + backgroundColor = .clear - contentContainer.backgroundColor = .secondarySystemGroupedBackground + contentContainer.backgroundColor = .clear contentContainer.translatesAutoresizingMaskIntoConstraints = false addSubview(contentContainer) NSLayoutConstraint.activate([ @@ -228,17 +399,26 @@ final class MobileHostingListRow: ListRowView, UIGestureRecognizerDelegate { hostedView = nil onSelect = nil pressOrigin = nil - contentContainer.backgroundColor = .secondarySystemGroupedBackground + restingBackgroundColor = .clear + backgroundColor = .clear + contentContainer.backgroundColor = .clear } - func configure(rootView: AnyView, onSelect: (() -> Void)?) { - hostedView?.removeFromSuperview() - let hostedView = UIHostingConfiguration { + func configure(rootView: AnyView, onSelect: (() -> Void)?, backgroundColor: UIColor) { + restingBackgroundColor = backgroundColor + self.backgroundColor = backgroundColor + contentContainer.backgroundColor = backgroundColor + let configuration = UIHostingConfiguration { rootView } .margins(.all, 0) .background(.clear) - .makeContentView() + if let contentView = hostedView as? any UIContentView { + contentView.configuration = configuration + self.onSelect = onSelect + return + } + let hostedView = configuration.makeContentView() hostedView.translatesAutoresizingMaskIntoConstraints = false contentContainer.addSubview(hostedView) NSLayoutConstraint.activate([ @@ -285,7 +465,7 @@ final class MobileHostingListRow: ListRowView, UIGestureRecognizerDelegate { options: [.beginFromCurrentState, .allowUserInteraction] ) { self.contentContainer.backgroundColor = - isPressed ? .tertiarySystemFill : .secondarySystemGroupedBackground + isPressed ? .tertiarySystemFill : self.restingBackgroundColor } } diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 34af66b7..d228c3c2 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -130,6 +130,18 @@ struct MobileWorkspaceTests { @Suite struct MobileTerminalShiftStateTests { + @Test func controlShortcutsPreserveOrderAndKeyPresses() { + #expect(MobileTerminalControlShortcut.allCases.map(\.label) == ["^C", "^I", "^S", "^Z"]) + #expect( + MobileTerminalControlShortcut.allCases.map(\.keyPress) + == [ + TerminalKeyPress(.c, modifiers: .ctrl), + TerminalKeyPress(.i, modifiers: .ctrl), + TerminalKeyPress(.s, modifiers: .ctrl), + TerminalKeyPress(.z, modifiers: .ctrl), + ]) + } + @Test func armedShiftAppliesOnce() { var state = MobileTerminalShiftState() state.toggle(at: Date(timeIntervalSince1970: 1)) diff --git a/TFITests/MobileAssistantRenderingTests.swift b/TFITests/MobileAssistantRenderingTests.swift index 7611e4df..6c1da15f 100644 --- a/TFITests/MobileAssistantRenderingTests.swift +++ b/TFITests/MobileAssistantRenderingTests.swift @@ -2,6 +2,7 @@ import AgentKit import Foundation import SwiftUI import TermindApplication +import TermindPresentation import Testing import UIKit @@ -9,7 +10,98 @@ import UIKit @Suite(.serialized) @MainActor struct MobileAssistantRenderingTests { - @Test func renderNativeAssistantAndArchive() async throws { + @Test func renderUserImageAlignment() async throws { + guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } + let app = try MobileAppModel(inMemory: true) + let conversation = ConversationModel() + for index in 0..<7 { + let size = + index.isMultiple(of: 2) + ? CGSize(width: 240, height: 120) + : CGSize(width: 120, height: 240) + let image = UIGraphicsImageRenderer(size: size).image { context in + UIColor( + hue: CGFloat(index) / 7, + saturation: 0.7, + brightness: 0.85, + alpha: 1 + ).setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + let attachment = AgentImageAttachment( + label: "preview-\(index).png", + mimeType: "image/png", + data: try #require(image.pngData()), + pixelWidth: Int(size.width), + pixelHeight: Int(size.height) + ) + conversation.attach( + ContextChip( + id: attachment.id, + kind: .image, + label: attachment.label, + image: attachment + ) + ) + } + conversation.appendUserTurn("Compare these images.") + app.assistant.library.insertAndSelect(conversation) + + try await capture( + "user-images", + language: "en", + view: NavigationStack { + MobileAssistantView(app: app, model: app.assistant) + } + ) + } + + @Test func renderTranscriptWorkStates() async throws { + guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } + for language in ["en", "zh-Hans"] { + for phase in AssistantTranscriptPreview.Phase.allCases { + let group = AssistantTranscriptPreview.work( + language: language == "en" ? .english : .simplifiedChinese, phase: phase + ) + let presentation = AssistantTranscriptPresentationState() + presentation.followDefault(true, for: .work(group.id)) + for item in group.items { + switch item { + case .tool(let card): + presentation.followDefault(true, for: .tool(card.id)) + case .tools(let batch): + presentation.followDefault(true, for: .toolBatch(batch.id)) + for card in batch.cards { + presentation.followDefault(true, for: .tool(card.id)) + } + case .reasoning, .message: + break + } + } + let row = MobileAssistantWorkRow( + group: group, presentation: presentation, + hostName: { _ in "Production" } + ) + for dark in [false, true] { + try await capture( + "work-\(phase.rawValue)-\(dark ? "dark" : "light")", language: language, + view: ScrollView { row.padding(20) } + .environment(\.locale, Locale(identifier: language)) + .environment(\.colorScheme, dark ? .dark : .light) + ) + } + try await capture( + "work-\(phase.rawValue)-large-reduced", language: language, + view: ScrollView { row.padding(20) } + .environment(\.locale, Locale(identifier: language)) + .environment(\.dynamicTypeSize, .accessibility2) + .environment(\.mobileListReduceMotion, true) + ) + } + } + } + + @Test func renderNativeAssistant() async throws { guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } for language in ["en", "zh-Hans"] { let app = try MobileAppModel(inMemory: true) @@ -35,19 +127,87 @@ struct MobileAssistantRenderingTests { view: NavigationStack { MobileAssistantView(app: app, model: app.assistant) }.environment(\.locale, Locale(identifier: language))) + conversation.composer.draft = "" + conversation.beginCompaction() + try await capture( + "compact-running", language: language, + view: NavigationStack { + MobileAssistantView(app: app, model: app.assistant) + }.environment(\.locale, Locale(identifier: language))) + conversation.failCompaction( + language == "en" ? "The context service is unavailable." : "上下文服务不可用。" + ) + try await capture( + "compact-failed", language: language, + view: NavigationStack { + MobileAssistantView(app: app, model: app.assistant) + }.environment(\.locale, Locale(identifier: language))) + conversation.endCompaction() + conversation.appendUserTurn( + language == "en" ? "Preserve the latest service diagnosis." : "保留最新的服务诊断。" + ) + let compacted = conversation.applyCompaction( + summary: language == "en" ? "Earlier context summary" : "早期上下文摘要", + cut: conversation.transcript.count + ) + #expect(compacted != nil) + #expect(conversation.timeline.contains { if case .compaction = $0 { true } else { false } }) + try await capture( + "compact-completed", language: language, + view: NavigationStack { + MobileAssistantView(app: app, model: app.assistant) + }.environment(\.locale, Locale(identifier: language))) + conversation.composer.attachmentImportError = + language == "en" ? "The selected attachment could not be imported." : "无法导入所选附件。" + try await capture( + "attachment-error", language: language, + view: NavigationStack { + MobileAssistantView(app: app, model: app.assistant) + }.environment(\.locale, Locale(identifier: language))) + conversation.composer.attachmentImportError = nil + conversation.handle( + .tasksUpdated([ + AgentTask( + id: "verify", + title: language == "en" ? "Verify the selected approach" : "验证所选方案", + status: .inProgress + ) + ])) + conversation.present( + AgentUserInputRequest(questions: [ + AgentUserQuestion( + id: "deployment", + header: language == "en" ? "Deployment" : "部署", + prompt: language == "en" ? "Where should this run?" : "应在哪里运行?", + kind: .choice( + AgentUserInputChoice(options: [ + AgentUserInputOption( + id: "staging", + label: language == "en" ? "Staging" : "预发布环境", + detail: language == "en" ? "Validate before production." : "上线前先验证。", + isRecommended: true + ), + AgentUserInputOption( + id: "production", + label: language == "en" ? "Production" : "生产环境", + detail: language == "en" + ? "Apply directly to live traffic." : "直接应用到线上流量。" + ), + ])) + ) + ])) + try await capture( + "question", language: language, + view: NavigationStack { + MobileAssistantView(app: app, model: app.assistant) + }.environment(\.locale, Locale(identifier: language))) + conversation.cancelUserInput() conversation.archivedAt = .now conversation.isPinned = false - try await capture( - "archive", language: language, - view: NavigationStack { - MobileAssistantLibrary(model: app.assistant, archived: true, selected: {}).navigationTitle( - "Archived") - }.environment(\.locale, Locale(identifier: language))) try await capture( "archived-chat", language: language, view: NavigationStack { - MobileAssistantConversation(app: app, model: app.assistant, conversation: conversation) - .navigationTitle("Assistant") + MobileAssistantView(app: app, model: app.assistant) }.environment(\.locale, Locale(identifier: language))) app.assistant.newConversation() try await capture( @@ -58,6 +218,40 @@ struct MobileAssistantRenderingTests { } } + @Test func renderNativeConversationLibraries() async throws { + guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } + for language in ["en", "zh-Hans"] { + let app = try MobileAppModel(inMemory: true) + let pinned = ConversationModel.demo(language: language == "en" ? .english : .simplifiedChinese) + pinned.isPinned = true + let ordinary = ConversationModel() + ordinary.customTitle = language == "en" ? "Review deployment logs" : "检查部署日志" + let streaming = ConversationModel(runState: .running) + streaming.customTitle = language == "en" ? "Investigate database latency" : "调查数据库延迟" + app.assistant.library.insertAndSelect(pinned) + app.assistant.library.insertAndSelect(ordinary) + app.assistant.library.insertAndSelect(streaming) + try await capture( + "conversations", language: language, + view: NavigationStack { + MobileAssistantLibrary( + model: app.assistant, route: .conversations, + selected: {}, showArchived: {} + ).navigationTitle("Conversations") + }.environment(\.locale, Locale(identifier: language))) + pinned.archivedAt = .now + pinned.isPinned = false + try await capture( + "archive", language: language, + view: NavigationStack { + MobileAssistantLibrary( + model: app.assistant, route: .archived, + selected: {}, showArchived: {} + ).navigationTitle("Archived") + }.environment(\.locale, Locale(identifier: language))) + } + } + private func capture(_ name: String, language: String, view: V) async throws { let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) let window = UIWindow(windowScene: scene) diff --git a/TFITests/MobileAssistantTests.swift b/TFITests/MobileAssistantTests.swift index 9ca97f2c..21c0116f 100644 --- a/TFITests/MobileAssistantTests.swift +++ b/TFITests/MobileAssistantTests.swift @@ -1,16 +1,125 @@ +import AVFoundation import AgentKit import Foundation +import SwiftUI import TermindApplication import TermindDomain import TermindInfrastructure import TermindPresentation import TermindRemoteInfrastructure import Testing +import UIKit +import UniformTypeIdentifiers @testable import TermindIOS @Suite @MainActor struct MobileAssistantTests { + @Test func shortUserInputUsesItsNaturalHeightAndLongInputUsesTheCap() throws { + let shortApp = try MobileAppModel(inMemory: true) + shortApp.assistant.conversation.present( + AgentUserInputRequest(questions: [ + AgentUserQuestion(id: "answer", header: "Answer", prompt: "Continue?", kind: .text) + ])) + let shortHeight = measuredInteractionHeight( + conversation: shortApp.assistant.conversation, + hosts: shortApp.library + ) + + let longApp = try MobileAppModel(inMemory: true) + let options = (0..<16).map { index in + AgentUserInputOption( + id: "option-\(index)", + label: "Option \(index)", + detail: "A deliberately detailed explanation that makes this option require another line." + ) + } + longApp.assistant.conversation.present( + AgentUserInputRequest(questions: [ + AgentUserQuestion( + id: "choice", + header: "Choose", + prompt: "Select an option.", + kind: .choice(AgentUserInputChoice(options: options)) + ) + ])) + let longHeight = measuredInteractionHeight( + conversation: longApp.assistant.conversation, + hosts: longApp.library + ) + + #expect(shortHeight > 0) + #expect(shortHeight < 300) + #expect(abs(longHeight - 300) < 0.5) + #expect(shortHeight < longHeight) + } + + @Test func longApprovalUsesTheSameHeightCap() async throws { + let app = try MobileAppModel(inMemory: true) + let conversation = app.assistant.conversation + let invocation = AgentToolInvocation( + runID: UUID(), + call: AgentToolCall( + id: "approval", + name: "write", + arguments: .object( + Dictionary( + uniqueKeysWithValues: (0..<20).map { index in + ("argument-\(index)", AgentJSONValue.string(String(repeating: "value ", count: 12))) + }) + ) + ) + ) + let request = AgentApprovalRequest( + invocation: invocation, + descriptor: AgentToolDescriptor( + name: "write", + summary: "Write data", + inputSchema: .object([:]), + target: .local, + safety: .requiresAuthorization + ), + userIntent: "Update the requested data", + localReasons: (0..<8).map { "Reason \($0) requires confirmation." } + ) + let approval = Task { await conversation.requestApproval(request) } + await Task.yield() + + let height = measuredInteractionHeight(conversation: conversation, hosts: app.library) + + #expect(abs(height - 300) < 0.5) + conversation.respondToApproval(allow: false) + if case .deny = await approval.value { + } else { + Issue.record("Expected the approval request to be denied during cleanup") + } + } + + @Test func speechTapHandlerRunsOnTheRealtimeQueueWithoutActorIsolation() async throws { + let format = try #require(AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 1)) + let buffer = try #require(AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 1)) + buffer.frameLength = 1 + buffer.floatChannelData?[0][0] = 0.25 + let probe = SpeechTapProbe() + let invocation = SpeechTapInvocation( + buffer: buffer, + handler: IOSSpeechAudioInput.makeTapHandler( + bufferHandler: { _ in probe.recordBuffer() }, + levelHandler: { probe.recordLevel($0) } + ) + ) + + await withCheckedContinuation { continuation in + DispatchQueue.global().async { + invocation.handler(invocation.buffer, AVAudioTime(hostTime: 0)) + continuation.resume() + } + } + + #expect(probe.snapshot().buffers == 1) + #expect(probe.snapshot().level > 0) + } + @Test func sharedTranscriptProjectionKeepsStableKindsAndEditCapabilities() { let user = ChatMessage(role: .user, text: "Inspect") let assistant = ChatMessage(role: .assistant, text: "Done") @@ -46,14 +155,160 @@ struct MobileAssistantTests { #expect(first.composer.draft == "first draft") } - @Test func slashCatalogCarriesNamesAndDescriptionsIntoTheMobilePanel() throws { + @Test func explicitNewConversationStartsWithAFreshDraft() throws { + let app = try MobileAppModel(inMemory: true) + app.assistant.conversation.composer.draft = "discard this explicit new-chat draft" + + app.assistant.newConversation() + + #expect(app.assistant.conversation.isEmpty) + #expect(app.assistant.conversation.composer.draft.isEmpty) + } + + @Test func firstNewConversationSendPromotesDraftAndStartsRun() throws { + let app = try MobileAppModel(inMemory: true) + let draft = app.assistant.conversation + draft.composer.draft = "Why is the service unavailable?" + + app.assistant.send() + + #expect(app.assistant.library.selectedConversation === draft) + #expect(app.assistant.library.conversations.map(\.id) == [draft.id]) + #expect(app.assistant.library.draftConversation !== draft) + #expect(draft.composer.draft.isEmpty) + #expect(draft.messages.map(\.role) == [.user, .assistant]) + #expect(draft.messages.first?.text == "Why is the service unavailable?") + #expect(draft.transcript.map(\.role) == [.user]) + } + + @Test func attachmentOnlyFirstSendPromotesTheConversation() throws { + let app = try MobileAppModel(inMemory: true) + let draft = app.assistant.conversation + draft.attach(ContextChip(kind: .file, label: "service.log", content: "connection refused")) + + app.assistant.send() + + #expect(app.assistant.library.selectedConversation === draft) + #expect(app.assistant.library.conversations.map(\.id) == [draft.id]) + #expect(draft.messages.map(\.role) == [.user, .assistant]) + #expect(draft.messages.first?.text.isEmpty == true) + #expect(draft.messages.first?.contextChips.map(\.label) == ["service.log"]) + #expect(draft.transcript.first?.text.contains("connection refused") == true) + } + + @Test func existingConversationSendStillUsesTheDraftExecutionPath() async throws { + let app = try MobileAppModel(inMemory: true) + let conversation = ConversationModel() + conversation.composer.draft = "Opening question" + _ = app.assistant.library.startConversation(conversation) + conversation.composer.draft = "Follow-up question" + + app.assistant.send() + + #expect(conversation.composer.draft.isEmpty) + #expect(conversation.messages.map(\.text).contains("Follow-up question")) + for _ in 0..<20 { + if conversation.messages.last?.role == .assistant { break } + await Task.yield() + } + #expect(conversation.messages.map(\.role) == [.user, .user, .assistant]) + #expect(conversation.transcript.map(\.role) == [.user, .user]) + } + + @Test func slashCatalogShowsAlphabetizedCompactPlanAndSkillCommandsOnMobile() throws { let app = try MobileAppModel(inMemory: true) let conversation = ConversationModel(messages: [ChatMessage(role: .user, text: "Inspect")]) app.assistant.library.insertAndSelect(conversation) let composer = MobileAssistantComposer(pro: app.pro, model: app.assistant, conversation: conversation) - let compact = try #require(composer.availableCommands.first { $0.id == "compact" }) - #expect(compact.name == "/compact") - #expect(!compact.summary.isEmpty) + let commands = composer.availableCommands + let first = try #require(commands.first) + #expect(commands.contains { $0.id == "plan" }) + #expect(commands.contains { $0.id == "compact" }) + #expect(Set(commands.map(\.id)).isSubset(of: Set(["compact", "plan", "skill"]))) + #expect( + commands.map(\.displayName) + == commands.map(\.displayName).sorted { + $0.localizedCaseInsensitiveCompare($1) == .orderedAscending + } + ) + #expect(!commands.contains { !$0.isShownOnMobile }) + #expect(!first.summary.isEmpty) + } + + @Test func selectingAnExplicitModelRemembersItAndClampsEffort() throws { + let app = try MobileAppModel(inMemory: true) + var reasoning = AIModel(id: "reasoning-model") + reasoning.abilities.insert(.reasoning) + reasoning.reasoningProfile = ReasoningProfile(levels: [ + .off: ReasoningLevelMapping(isSupported: true), + .high: ReasoningLevelMapping(isSupported: true), + ]) + var plain = AIModel(id: "plain-model") + plain.reasoningProfile = ReasoningProfile(levels: [ + .off: ReasoningLevelMapping(isSupported: true) + ]) + let provider = ModelProvider(name: "Test", models: [reasoning, plain]) + app.ai.upsert(provider) + + app.assistant.conversation.reasoningEffort = .low + let reasoningRef = AIModelRef(modelProviderID: provider.id, modelID: reasoning.id) + app.assistant.selectModel(reasoningRef) + #expect(app.assistant.conversation.model == reasoningRef) + #expect(app.ai.lastSelectedModel == reasoningRef) + #expect(app.assistant.conversation.reasoningEffort == .high) + + let plainRef = AIModelRef(modelProviderID: provider.id, modelID: plain.id) + app.assistant.selectModel(plainRef) + #expect(app.assistant.conversation.reasoningEffort == .off) + } + + @Test func assistantDropImporterAcceptsFileURLsAndImageData() async throws { + let app = try MobileAppModel(inMemory: true) + let conversation = app.assistant.conversation + let directory = FileManager.default.temporaryDirectory.appending( + path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: "service.log") + try Data("connection refused".utf8).write(to: file) + let image = UIGraphicsImageRenderer(size: CGSize(width: 8, height: 8)).image { context in + UIColor.systemBlue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 8, height: 8)) + } + let imageData = try #require(image.pngData()) + + let fileProvider = try #require(NSItemProvider(contentsOf: file)) + let imageProvider = NSItemProvider( + item: imageData as NSData, + typeIdentifier: UTType.png.identifier + ) + let payload = await MobileDropPayloadLoader.load( + [fileProvider, imageProvider], + kinds: [.files, .images], + fileMode: .staged + ) + + let stagedFile = try #require(payload.fileURLs.first) + defer { + if let stagedRoot = payload.stagedRoot { + try? FileManager.default.removeItem(at: stagedRoot) + } + } + #expect(stagedFile != file) + #expect(stagedFile.lastPathComponent == file.lastPathComponent) + #expect(try Data(contentsOf: stagedFile) == Data("connection refused".utf8)) + #expect(payload.images == [imageData]) + #expect(payload.failures.isEmpty) + conversation.importAttachmentFiles(payload.fileURLs) + conversation.importAttachmentImages(payload.images, label: String(localized: "Dropped image")) + #expect(await eventually { conversation.composer.pendingAttachments.isEmpty }) + #expect(Set(conversation.composer.attachments.map(\.label)) == ["service.log", "Dropped image"]) + #expect( + conversation.composer.attachments.contains { + $0.label == "service.log" && $0.content == "connection refused" + } + ) + #expect(conversation.composer.attachments.contains { $0.image != nil }) } @Test func completeRegistryIncludesRemoteAndBuiltInToolsOnlyForCapableModels() throws { @@ -100,4 +355,52 @@ struct MobileAssistantTests { #expect(!app.assistant.isSuspending) #expect(!app.assistant.isRunning) } + + private func measuredInteractionHeight( + conversation: ConversationModel, + hosts: HostLibraryModel, + width: CGFloat = 390 + ) -> CGFloat { + let controller = UIHostingController( + rootView: MobileAssistantInteraction(conversation: conversation, hosts: hosts) + .environment(\.dynamicTypeSize, .large) + ) + return controller.sizeThatFits(in: CGSize(width: width, height: 1_000)).height + } + + private func eventually(_ condition: @escaping @MainActor () -> Bool) async -> Bool { + for _ in 0..<100 { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return false + } +} + +nonisolated private final class SpeechTapInvocation: @unchecked Sendable { + let buffer: AVAudioPCMBuffer + let handler: AVAudioNodeTapBlock + + init(buffer: AVAudioPCMBuffer, handler: @escaping AVAudioNodeTapBlock) { + self.buffer = buffer + self.handler = handler + } +} + +nonisolated private final class SpeechTapProbe: @unchecked Sendable { + private let lock = NSLock() + private var buffers = 0 + private var level: Float = 0 + + func recordBuffer() { + lock.withLock { buffers += 1 } + } + + func recordLevel(_ value: Float) { + lock.withLock { level = value } + } + + func snapshot() -> (buffers: Int, level: Float) { + lock.withLock { (buffers, level) } + } } diff --git a/TFITests/MobileAssistantTranscriptTests.swift b/TFITests/MobileAssistantTranscriptTests.swift new file mode 100644 index 00000000..4b3ff105 --- /dev/null +++ b/TFITests/MobileAssistantTranscriptTests.swift @@ -0,0 +1,253 @@ +import AgentKit +import ListViewKit +import SwiftUI +import TermindApplication +import TermindPresentation +import Testing +import UIKit + +@testable import TermindIOS + +@Suite(.serialized) @MainActor +struct MobileAssistantTranscriptTests { + @Test func userBubbleShrinksForShortTextAndWrapsAtTheAvailableWidth() { + func measuredWidth(_ text: String) -> CGFloat { + let controller = UIHostingController(rootView: MobileAssistantUserBubble(text: text)) + return controller.sizeThatFits(in: CGSize(width: 320, height: 1_000)).width + } + + let short = measuredWidth("Hi") + let long = measuredWidth(String(repeating: "A long message that should wrap. ", count: 20)) + #expect(short < 160) + #expect(long > short) + #expect(long <= 320) + } + + @Test func imageFlowKeepsEveryUserRowTrailingAndAssistantRowsLeading() { + let tile = CGSize(width: 104, height: 104) + let sizes = Array(repeating: tile, count: 5) + + let user = MobileAssistantImageFlowLayout.placements( + for: sizes, + in: 320, + spacing: 8, + alignment: .trailing + ) + #expect(user.map(\.index) == [0, 1, 2, 3, 4]) + #expect(user.map(\.size) == sizes) + #expect(user[0].origin == CGPoint(x: 104, y: 0)) + #expect(user[1].origin == CGPoint(x: 216, y: 0)) + #expect(user[2].origin == CGPoint(x: 104, y: 112)) + #expect(user[3].origin == CGPoint(x: 216, y: 112)) + #expect(user[4].origin == CGPoint(x: 216, y: 224)) + #expect(user[1].origin.x + user[1].size.width == 320) + #expect(user[3].origin.x + user[3].size.width == 320) + #expect(user[4].origin.x + user[4].size.width == 320) + + let assistant = MobileAssistantImageFlowLayout.placements( + for: sizes, + in: 320, + spacing: 8, + alignment: .leading + ) + #expect(assistant[0].origin.x == 0) + #expect(assistant[2].origin.x == 0) + #expect(assistant[4].origin.x == 0) + } + + @Test func scanningStopsForReducedMotionMeasurementAndInactiveScenes() async throws { + // This owns a foreground window, so run it with the opt-in visual checks + // rather than competing with unrelated native toolbar tests. + guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } + let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) + for (displayed, reduced, active) in [ + (true, false, true), (true, true, true), (false, false, true), (true, false, false), + ] { + let window = UIWindow(windowScene: scene) + let controller = UIHostingController( + rootView: + Text("Working…").font(.title).foregroundStyle(.black) + .modifier(MobileAssistantScanning(active: true)) + .frame(maxWidth: .infinity, maxHeight: .infinity).background(.white) + .environment(\.mobileListRowIsDisplayed, displayed) + .environment(\.mobileListReduceMotion, reduced) + .environment(\.mobileListSceneIsActive, active) + ) + window.rootViewController = controller + window.windowLevel = .alert + 3 + window.makeKeyAndVisible() + defer { + window.isHidden = true + window.rootViewController = nil + } + controller.view.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(300)) + func snapshot() throws -> Data { + let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + return try #require(image.pngData()) + } + let first = try snapshot() + try await Task.sleep(for: .milliseconds(250)) + let second = try snapshot() + let directory = FileManager.default.temporaryDirectory.appending(path: "assistant-screenshots") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try first.write(to: directory.appending(path: "motion-\(displayed)-\(reduced)-\(active)-first.png")) + try second.write(to: directory.appending(path: "motion-\(displayed)-\(reduced)-\(active)-second.png")) + if displayed && !reduced && active { + #expect(first != second, "The live title should scan.") + } else { + #expect(first == second, "A static title must not animate.") + } + } + } + + @Test func streamedUpdatesAndDisclosureRevisionsKeepTheHostedView() throws { + let harness = Harness() + let first = message("First") + let second = message("Second") + harness.update([first, second]) + let row = try #require(harness.list.rowView(for: first.id)) + let hosted = try #require(row.subviews.first?.subviews.first) + harness.renderedIDs.removeAll() + var streaming = first + streaming.text += " delta" + harness.update([streaming, second]) + #expect(harness.list.rowView(for: first.id) === row) + #expect(row.subviews.first?.subviews.first === hosted) + #expect(!harness.renderedIDs.contains(second.id)) + harness.renderedIDs.removeAll() + harness.revisions[first.id] = 1 + harness.update([streaming, second]) + #expect(harness.renderedIDs.contains(first.id)) + #expect(!harness.renderedIDs.contains(second.id)) + #expect(row.subviews.first?.subviews.first === hosted) + } + + @Test func appendingDoesNotReplaceExistingRowsOrFollowWhileReadingHistory() async throws { + let harness = Harness() + let items = (0..<30).map { message("Message \($0)") } + harness.update(items) + harness.list.setContentOffset(CGPoint(x: 0, y: 120), animated: false) + harness.list.layoutIfNeeded() + let offset = harness.list.contentOffset.y + let visible = try #require(harness.list.indicesForVisibleRows.first) + let row = try #require(harness.list.rowView(for: items[visible].id)) + harness.update(items + [message("New tail")], followsBottom: true) + try await Task.sleep(for: .milliseconds(30)) + #expect(harness.list.rowView(for: items[visible].id) === row) + #expect(abs(harness.list.contentOffset.y - offset) < 1) + } + + @Test func visibleWorkCollapsesOnlyAfterItFullyLeavesTheMobileViewport() async throws { + typealias Adapter = MobileNativeList + let presentation = AssistantTranscriptPresentationState() + let coordinator = Adapter.Coordinator() + let list = ListView() + list.frame = CGRect(x: 0, y: 0, width: 390, height: 180) + list.rows { + ListRow(MobileHostingListRow.self).height { _, _ in 100 }.configure { row, item, _ in + coordinator.configure(row, item: item, surface: .plain) + } + } + coordinator.observeViewport(of: list) + defer { coordinator.stopObservingViewport() } + let id = UUID() + let tail = (0..<12).map { _ in AssistantTranscriptEntry(id: UUID(), content: .empty) } + func update(_ state: AgentRunState) { + let run = AgentRunSummary(id: id, state: state, startedAt: .now) + let entry = AssistantTranscriptEntry( + id: id, + content: .work( + .init( + run: run, items: [], orderedAt: .now, appearsAfterAnswer: false + ))) + coordinator.update( + listView: list, items: [entry] + tail, renderer: { _ in AnyView(Text("Work")) }, + onSelect: nil, onRefresh: nil, followsBottom: false, surface: .plain, + refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, + prepareUpdate: { presentation.prepareWorkCompletionTransitions(from: $0, to: $1, visibleIDs: $2) }, + onViewportChange: { presentation.collapseWorkOutsideViewport(visibleIDs: $0) } + ) + list.layoutIfNeeded() + } + update(.running) + update(.completed) + #expect(presentation.hasPendingAutoCollapse(id)) + let frame = list.rectForRow(with: id) + list.setContentOffset(CGPoint(x: 0, y: frame.maxY - 1), animated: false) + list.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(30)) + #expect(presentation.isExpanded(.work(id))) + list.setContentOffset(CGPoint(x: 0, y: frame.maxY + 1), animated: false) + list.layoutIfNeeded() + try await Task.sleep(for: .milliseconds(30)) + #expect(!presentation.isExpanded(.work(id))) + #expect(!presentation.hasPendingAutoCollapse(id)) + } + + @Test func nativeListTapCallbackRecognizesAlongsideHostedRowControls() throws { + typealias Adapter = MobileNativeList + let coordinator = Adapter.Coordinator() + let list = ListView() + var tapCount = 0 + coordinator.installTapRecognizer(on: list) + coordinator.update( + listView: list, + items: [], + renderer: { _ in AnyView(Text("Message")) }, + onSelect: nil, + onRefresh: nil, + followsBottom: false, + surface: .plain, + refreshID: 0, + locale: Locale(identifier: "en"), + colorScheme: .light, + dynamicTypeSize: .large, + onTap: { tapCount += 1 } + ) + + let tap = try #require(list.gestureRecognizers?.compactMap { $0 as? UITapGestureRecognizer }.first) + #expect(tap.cancelsTouchesInView == false) + #expect(tap.delegate === coordinator) + #expect( + coordinator.gestureRecognizer( + tap, + shouldRecognizeSimultaneouslyWith: UITapGestureRecognizer() + )) + coordinator.listTapped() + #expect(tapCount == 1) + } + + private func message(_ text: String) -> ChatMessage { .init(role: .assistant, text: text, isStreaming: true) } + + private final class Harness { + let coordinator = MobileNativeList.Coordinator() + let list = ListView() + var revisions: [UUID: UInt64] = [:] + var renderedIDs: [UUID] = [] + + init() { + list.frame = CGRect(x: 0, y: 0, width: 390, height: 280) + list.rows { + ListRow(MobileHostingListRow.self).height { _, _ in 80 }.configure { [weak self] row, item, _ in + self?.coordinator.configure(row, item: item, surface: .plain) + } + } + } + + func update(_ items: [ChatMessage], followsBottom: Bool = false) { + coordinator.update( + listView: list, items: items, + renderer: { [weak self] item in + self?.renderedIDs.append(item.id) + return AnyView(Text(item.text)) + }, onSelect: nil, onRefresh: nil, followsBottom: followsBottom, surface: .plain, + refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, + rowRevision: { [weak self] in self?.revisions[$0.id] ?? 0 } + ) + list.layoutIfNeeded() + } + } +} diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift index 7a4003c2..c2d340dc 100644 --- a/TFITests/MobileNavigationParityTests.swift +++ b/TFITests/MobileNavigationParityTests.swift @@ -15,25 +15,156 @@ struct MobileNavigationParityTests { contentsOf: root.appending(path: "TFI/Features/Assistant/MobileAssistantView.swift"), encoding: .utf8) let transcript = try String( 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 currentConversationMenu = try #require( assistant.components(separatedBy: ".toolbar {").dropFirst().first? .components(separatedBy: ".navigationDestination").first) + let titleMenu = try #require( + assistant.components(separatedBy: "@ViewBuilder private var conversationTitleMenu").last? + .components(separatedBy: "private func titleMenuButton").first) + let bottomSurface = try #require( + transcript.components(separatedBy: "@ViewBuilder private var bottomSurface").last? + .components(separatedBy: "private var archivedNotice").first) + let assistantRows = try #require( + 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) #expect(assistant.contains(".toolbarTitleMenu")) + #expect(assistant.contains("@State private var libraryRoute: MobileAssistantLibraryRoute?")) + #expect(assistant.contains(".navigationDestination(item: $libraryRoute)")) #expect(assistant.contains("List {")) #expect(!assistant.contains("MobileNativeList(")) + #expect(!assistant.contains(".listStyle(.plain)")) + #expect(!assistant.contains(".scrollContentBackground(.hidden)")) + #expect(!assistant.contains(".background(Color(uiColor: .systemBackground))")) + #expect(!assistant.contains("minHeight: 48")) + #expect(!assistantRows.contains("minHeight: 60")) + #expect(!assistantRows.contains(".font(.headline)")) + #expect(!assistantRows.contains(".lineLimit(2)")) + #expect(assistantRows.contains("Text(conversation.derivedTitle).foregroundStyle(.primary).lineLimit(1)")) + for layout in [ + "HStack(spacing: 12)", + ".frame(width: 28, height: 28)", + "VStack(alignment: .leading, spacing: 2)", + "Spacer(minLength: 0)", + ".frame(maxWidth: .infinity, alignment: .leading)", + ] { + #expect(assistantRows.contains(layout)) + #expect(workspaceRows.contains(layout)) + } #expect(!assistant.contains("geometry.size.width >= 760")) #expect(!assistant.contains("MobileAssistantLibrary(model: model, selected: {})")) #expect(assistant.contains(".swipeActions(edge: .trailing, allowsFullSwipe: true)")) #expect(assistant.contains("Button(\"Archive\", systemImage: \"archivebox\")")) - #expect(assistant.contains("Button(\"Unarchive\", systemImage: \"tray.and.arrow.up\")")) + #expect(assistant.contains("Button(\"Unarchive\", systemImage: \"arrow.uturn.backward\")")) #expect(!assistant.contains("MobileCardIcon(symbol: conversation.isArchived")) #expect(assistant.contains(".foregroundStyle(.secondary)")) - #expect(!currentConversationMenu.contains("Button(\"Conversations\"")) - #expect(!currentConversationMenu.contains("Button(\"Archived\"")) + let conversations = try #require(currentConversationMenu.range(of: "Button(\"Conversations\"")) + let archived = try #require(currentConversationMenu.range(of: "Button(\"Archived\"")) + let divider = try #require(currentConversationMenu.range(of: "Divider()")) + #expect(conversations.lowerBound < archived.lowerBound) + #expect(archived.lowerBound < divider.lowerBound) + #expect(!titleMenu.contains("\"New Conversation\"")) + #expect(!titleMenu.contains("Button(\"Conversations\"")) + #expect(!titleMenu.contains("Button(\"Archived\"")) #expect(transcript.contains("MobileNativeList(")) + #expect(transcript.contains("surface: .plain")) #expect(transcript.contains("AssistantTranscriptEntry.items(")) + #expect(transcript.contains("messageAction(\"Edit\"")) + #expect(transcript.contains("messageAction(\"Copy\"")) + #expect(transcript.contains("messageAction(\"Fork\"")) + #expect(!transcript.contains("Text(\"Assistant\")")) + #expect(transcript.contains("AssistantTranscriptPresentationState()")) + #expect(transcript.contains("DisclosureGroup(isExpanded: disclosureBinding")) #expect(!transcript.contains("LazyVStack")) + #expect(workRows.contains("if batch.cards.count == 1, let card = batch.cards.first")) + #expect(workRows.contains("defaultExpanded: true")) + #expect(workRows.contains("presentation.isExpanded(.toolBatch(batch.id))")) + #expect(workRows.contains("presentation.isExpanded(.tool(card.id), default: defaultExpanded)")) + #expect(bottomSurface.contains(".background(Color(uiColor: .systemBackground))")) + #expect(!bottomSurface.contains(".background(.bar)")) + } + + @Test func assistantBlockingInteractionsReplaceTheComposerAndSingleOptionsAdvance() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let conversation = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantConversation.swift"), encoding: .utf8) + let interaction = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantInteraction.swift"), encoding: .utf8) + let bottomSurface = try #require( + conversation.components(separatedBy: "@ViewBuilder private var bottomSurface").last? + .components(separatedBy: "private var archivedNotice").first) + let blockingSurface = try #require( + bottomSurface.components(separatedBy: "conversation.pendingUserInteraction != nil {").last? + .components(separatedBy: "} else {").first) + let composerSurface = try #require(bottomSurface.components(separatedBy: "} else {").last) + + #expect( + bottomSurface.contains( + "conversation.pendingApproval != nil || conversation.pendingUserInteraction != nil")) + #expect(blockingSurface.contains("MobileAssistantInteraction(")) + #expect(!blockingSurface.contains("MobileAssistantComposer(")) + #expect(!blockingSurface.contains("taskList")) + #expect(blockingSurface.contains(".background(Color(uiColor: .systemBackground))")) + #expect(composerSurface.contains("MobileAssistantComposer(")) + #expect(composerSurface.contains("taskList")) + #expect(!composerSurface.contains("MobileAssistantInteraction(")) + #expect(composerSurface.contains(".background(Color(uiColor: .systemBackground))")) + #expect(interaction.contains("conversation.answerUserInputOption(option.id, in: question.id)")) + #expect(!interaction.contains("conversation.toggleUserInputOption(option.id, in: question.id)")) + #expect(interaction.components(separatedBy: "MobileAssistantAdaptiveHeight {").count == 3) + #expect(interaction.contains("ViewThatFits(in: .vertical)")) + #expect(interaction.contains(".scrollBounceBehavior(.basedOnSize)")) + #expect(interaction.contains(".frame(maxWidth: .infinity, maxHeight: 300, alignment: .topLeading)")) + #expect(!interaction.contains(".frame(maxHeight: 160)")) + } + + @Test func assistantHoistsComposerFocusAndRestoresItOnlyAcrossRequestedNavigation() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let assistant = try String( + contentsOf: root.appending(path: "TFI/Features/Assistant/MobileAssistantView.swift"), encoding: .utf8) + let conversation = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantConversation.swift"), encoding: .utf8) + let composer = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantComposer.swift"), encoding: .utf8) + + #expect(assistant.contains("@FocusState private var composerFocused: Bool")) + #expect(assistant.contains("restoreComposerFocusAfterLibrary = composerFocused")) + #expect(assistant.contains("replaceConversation(preservingComposerFocus: model.newConversation)")) + #expect(assistant.contains("replaceConversation { model.select(conversation) }")) + #expect(assistant.contains("guard requested, canFocusComposer else { return }")) + #expect(assistant.contains("await Task.yield()")) + #expect(assistant.contains("composerFocusRequest = nextComposerFocusRequest")) + #expect(assistant.contains("composerFocusRequestHandled: composerFocusRequestHandled")) + #expect(conversation.contains("onTap: { composerFocus.wrappedValue = false }")) + #expect(conversation.contains("focus: composerFocus")) + #expect(composer.contains(".focused(activeFocus)")) + #expect(composer.contains(".task(id: focusRequest)")) + #expect(composer.contains("await Task.yield()")) + #expect(composer.contains("guard !Task.isCancelled else { return }")) + #expect(composer.contains("activeFocus.wrappedValue = true")) + #expect(composer.contains("focusRequestHandled(focusRequest)")) + } + + @Test func speechAudioTapDoesNotInheritMainActorIsolation() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = try String( + contentsOf: root.appending(path: "TFI/Platform/IOSSpeechAudioInput.swift"), encoding: .utf8) + + #expect(source.contains("nonisolated static func makeTapHandler")) + #expect(source.contains("block: Self.makeTapHandler")) } @Test func nativeListOmitsSwipeDeletionAndSFTPRowsKeepAlignedMetadataWithoutSeparators() throws { @@ -62,20 +193,128 @@ struct MobileNavigationParityTests { #expect(sftp.contains(".accessibilityHidden(true)")) } - @Test func assistantSlashRowsUseTwoTouchSizedLines() throws { + @Test func assistantSlashRowsUseThreeCompactTouchSizedLinesWithEvenVerticalPadding() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .deletingLastPathComponent() let slash = try String( contentsOf: root.appending(path: "TFI/Features/AI/MobileSlashCommandPanel.swift"), encoding: .utf8) - #expect(slash.contains("rowHeight = 88.0")) - #expect(slash.contains("headerHeight = 56.0")) + #expect(slash.contains("rowHeight = 64.0")) + #expect(!slash.contains("headerHeight")) + #expect(!slash.contains("private var header")) + #expect(!slash.contains("onBack")) + #expect(!slash.contains("onClose")) + #expect(slash.contains("min(completion.rows.count, 3)")) + #expect(slash.contains("HStack(alignment: .center, spacing: 12)")) + #expect(!slash.contains("HStack(alignment: .top, spacing: 12)")) + #expect(slash.contains(".padding(.vertical, 8)")) + #expect(!slash.contains("minHeight: 54")) + #expect(!slash.contains("maxHeight: 54")) #expect(slash.contains("Text(verbatim: command.summary)")) #expect(slash.contains("Text(verbatim: option.detail.isEmpty ? \" \" : option.detail)")) #expect(slash.contains(".lineLimit(1).truncationMode(.tail)")) } + @Test func assistantCompactionMatchesDesktopAndComposerShowsNoInlineErrors() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let composer = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantComposer.swift"), encoding: .utf8) + let conversation = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantConversation.swift"), encoding: .utf8) + + #expect(composer.contains("\"Attachment Error\"")) + #expect(composer.contains("get: { composer.attachmentImportError != nil }")) + #expect(!composer.contains("if let error = composer.attachmentImportError")) + #expect(!composer.contains("conversation.isCompacting { ProgressView")) + #expect(!composer.contains("conversation.compactionActivity?.state")) + + #expect(conversation.contains("case .compactionActivity(let activity): compactionActivityRow(activity)")) + #expect(conversation.contains("Text(\"Compacting context\")")) + #expect(conversation.contains("Text(\"Couldn’t compact context\")")) + #expect( + conversation.contains( + "DisclosureGroup(isExpanded: disclosureBinding(for: .compactionActivity(activity.id)))")) + #expect(conversation.contains(".tint(.secondary)")) + #expect(conversation.contains("Text(reason)")) + #expect(conversation.contains(".font(.caption.monospaced())")) + #expect(conversation.contains("ContextBreakdownView.compact(mark.summarizedTokens)")) + #expect(conversation.contains("ContextBreakdownView.compact(mark.keptTokens)")) + #expect(conversation.contains("systemImage: \"doc.text\"")) + #expect(conversation.contains("Earlier turns are summarized for the model. Nothing was deleted.")) + } + + @Test func assistantSeparatesEffectiveModelFromEffortWithoutAnAutomaticChoice() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let composer = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantComposer.swift"), encoding: .utf8) + + #expect(composer.contains("Section(\"Context\")")) + #expect(composer.contains("showsTitle: false")) + #expect(composer.contains("Section(\"Model\") {\n assistantModelPicker")) + #expect(composer.contains("Picker(\"Effort\"")) + #expect(!composer.contains(".pickerStyle(.inline)")) + #expect(composer.contains(".navigationTitle(\"Model\")")) + #expect(composer.contains("Button(\"Done\", systemImage: \"checkmark\")")) + #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(".onSubmit(submitFromKeyboard)")) + #expect(composer.contains(".onKeyPress(.escape")) + let badges = try #require( + composer.components(separatedBy: "private var badges").last? + .components(separatedBy: "private var modelProviders").first) + #expect(badges.contains("armedSkillName")) + #expect(badges.components(separatedBy: ".labelStyle(.iconOnly)").count == 3) + #expect(composer.contains(".symbolRenderingMode(.monochrome)")) + } + + @Test func assistantPermissionMenuUsesLeadingCheckmarkAndKeepsItsTriggerVisible() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let composer = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantComposer.swift"), encoding: .utf8) + let permissionMenu = try #require( + composer.components(separatedBy: "private var permissionMenu").last? + .components(separatedBy: "private var resolvedPermissionMode").first) + + #expect(!permissionMenu.contains("Picker(")) + #expect(permissionMenu.contains("Button(role: mode == .fullAccess ? .destructive : nil)")) + #expect(permissionMenu.contains("mode == resolvedPermissionMode ? \"checkmark\" : mode.symbol")) + #expect(permissionMenu.contains("Label(\"Permissions\", systemImage: resolvedPermissionMode.symbol)")) + #expect(permissionMenu.contains("? AnyShapeStyle(.red) : AnyShapeStyle(.primary)")) + #expect(permissionMenu.contains(".labelStyle(.iconOnly)")) + #expect(permissionMenu.contains(".fixedSize()")) + #expect(permissionMenu.contains(".layoutPriority(1)")) + #expect(permissionMenu.contains("conversation.permissionMode = mode")) + #expect(permissionMenu.contains("model.configuration.select(mode)")) + } + + @Test func assistantAddMenuContainsOnlyOrderedAttachmentSources() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let composer = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantComposer.swift"), encoding: .utf8) + let addMenu = try #require( + composer.components(separatedBy: "private var addMenu").last? + .components(separatedBy: "private var permissionMenu").first) + + let camera = try #require(addMenu.range(of: "Button(\"Camera\"")) + let photos = try #require(addMenu.range(of: "Button(\"Photos\"")) + let files = try #require(addMenu.range(of: "Button(\"Files\"")) + #expect(camera.lowerBound < photos.lowerBound) + #expect(photos.lowerBound < files.lowerBound) + #expect(!addMenu.contains("Voice Input")) + #expect(!addMenu.contains("Divider()")) + } + @Test func vaultReplacesWelcomeAndMetricsRemainsBelowItOnIPad() { #expect(MobileTab.allCases == [.vault, .workspaces, .assistant, .settings]) #expect(MobileDestination.allCases == [.vault, .metrics, .workspaces, .assistant, .settings]) @@ -213,14 +452,41 @@ struct MobileNavigationParityTests { contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceView.swift"), encoding: .utf8 ) + let workspaceView = try #require( + workspace.components(separatedBy: "struct MobileWorkspaceView: View").last + ) + let rootSurface = try #require( + workspaceView.components(separatedBy: "var body: some View").dropFirst().first? + .components(separatedBy: "private var workspaceChrome").first + ) #expect(workspace.contains("Button(\"Generate Command\", systemImage: \"sparkles\")")) #expect(workspace.contains(".keyboardShortcut(\"i\", modifiers: .command)")) - #expect(workspace.contains(".dropDestination(for: URL.self)")) + #expect(rootSurface.contains("of: [.fileURL]")) + #expect(rootSurface.contains("perform: receiveUploadProviders")) + #expect(rootSurface.contains("DropTargetOverlay(presentation: uploadDropPresentation)")) #expect(workspace.contains("allowedContentTypes: [.data, .folder]")) #expect(workspace.contains("allowsMultipleSelection: true")) } + @Test func assistantConversationAcceptsFilesAndImagesAcrossItsWholeSurface() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + let conversation = try String( + contentsOf: root.appending(path: "TFI/Features/AI/MobileAssistantConversation.swift"), + encoding: .utf8 + ) + + #expect(conversation.contains(".mobileConversationAttachmentDropTarget(")) + #expect(conversation.contains("of: [.fileURL, .image]")) + #expect(conversation.contains("MobileDropPayloadLoader.load")) + #expect(conversation.contains("DropTargetOverlay(")) + #expect(conversation.contains("!conversation.isArchived")) + #expect(conversation.contains("conversation.pendingApproval == nil")) + #expect(conversation.contains("conversation.pendingUserInteraction == nil")) + } + @Test func knownHostsExposeAlignedCardActionsAndEditing() throws { let root = URL(fileURLWithPath: #filePath) .deletingLastPathComponent() @@ -321,12 +587,17 @@ struct MobileNavigationParityTests { let newFolder = try #require(sftpGroups[1].range(of: "Button(\"New Folder\"")) let paste = try #require(sftpGroups[1].range(of: "Button(\"Paste\"")) let sort = try #require(sftpGroups[1].range(of: "sortMenu")) + let hiddenFiles = try #require( + sftpGroups[1].range(of: "if workspace.sftpSession.showsHiddenFiles")) let refresh = try #require(sftpGroups[1].range(of: "Button(\"Refresh\"")) #expect(!sftpGroups[1].contains("Parent Directory")) + #expect(sftpGroups[1].contains("Button(\"Hide Hidden Files\", systemImage: \"eye.slash\")")) + #expect(sftpGroups[1].contains("Button(\"Show Hidden Files\", systemImage: \"eye\")")) #expect(upload.lowerBound < newFolder.lowerBound) #expect(newFolder.lowerBound < paste.lowerBound) #expect(paste.lowerBound < sort.lowerBound) - #expect(sort.lowerBound < refresh.lowerBound) + #expect(sort.lowerBound < hiddenFiles.lowerBound) + #expect(hiddenFiles.lowerBound < refresh.lowerBound) let sftpSameDirectory = try #require(sftpGroups[2].range(of: "sameDirectoryMenuAction")) let sftpRename = try #require(sftpGroups[2].range(of: "Button(\"Rename\"")) let sftpClose = try #require(sftpGroups[2].range(of: "Button(\"Close\"")) diff --git a/TFITests/MobileSFTPSessionTests.swift b/TFITests/MobileSFTPSessionTests.swift index d4d08cf1..1e61ba09 100644 --- a/TFITests/MobileSFTPSessionTests.swift +++ b/TFITests/MobileSFTPSessionTests.swift @@ -80,6 +80,45 @@ struct MobileSFTPSessionTests { #expect(session.visibleFiles == [folder, notes, report]) } + @Test func hiddenFilesAreExcludedByDefaultAndCanBeShownWithinTheSession() async throws { + let hiddenFolder = remoteFile("/home/.cache", kind: .directory) + let visibleFolder = remoteFile("/home/Reports", kind: .directory) + let hiddenFile = remoteFile("/home/.env") + let hiddenLink = remoteFile("/home/.current", kind: .symbolicLink) + let visibleFile = remoteFile("/home/report.txt") + let fileSystem = MobileBrowserFileSystem( + listings: [ + "/home": [visibleFile, hiddenFile, visibleFolder, hiddenFolder, hiddenLink], + "/srv": [remoteFile("/srv/.ssh", kind: .directory)], + ]) + let session = MobileSFTPSessionModel() + try await attach(session, to: fileSystem, path: "/home") + + #expect(!session.showsHiddenFiles) + #expect(session.visibleFiles == [visibleFolder, visibleFile]) + + session.query = "ENV" + #expect(session.visibleFiles.isEmpty) + session.showsHiddenFiles = true + #expect(session.visibleFiles == [hiddenFile]) + + session.query = "" + #expect(Set(session.visibleFiles) == Set([hiddenFolder, visibleFolder, hiddenFile, hiddenLink, visibleFile])) + #expect(session.visibleFiles.prefix(2).allSatisfy { $0.isDirectory }) + + await session.openDirectory("/srv") + await session.reload() + #expect(session.showsHiddenFiles) + #expect(session.visibleFiles.map(\.name) == [".ssh"]) + + session.detach() + try await attach(session, to: fileSystem, path: "/home") + #expect(session.showsHiddenFiles) + + session.showsHiddenFiles = false + #expect(session.visibleFiles == [visibleFolder, visibleFile]) + } + @Test func sortMatchesDesktopDefaultsAndKeepsDirectoriesBeforeFiles() async throws { let older = RemoteFile( path: "/home/older.txt", name: "older.txt", kind: .file, size: 1_000, diff --git a/TFM/Design/DesignTokens.swift b/TFM/Design/DesignTokens.swift index b861c7f6..ecafe440 100644 --- a/TFM/Design/DesignTokens.swift +++ b/TFM/Design/DesignTokens.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import TermindPresentation /// Central layout constants. Kept as plain numbers rather than a theme object /// because none of these need to vary at runtime — only the terminal palette does. @@ -170,8 +171,8 @@ nonisolated enum Metrics { /// Compact Assistant surfaces continue to use all available width. static let contentMaxWidth: CGFloat = 840 static let turnSpacing: CGFloat = 20 - static let blockSpacing: CGFloat = 10 - static let bubbleRadius: CGFloat = 14 + static let blockSpacing = AssistantTranscriptStyle.blockSpacing + static let bubbleRadius = AssistantTranscriptStyle.bubbleRadius /// Sent images are center-cropped thumbnails rather than variable-height /// rows, so a batch reads as one compact grid. static let imageTile: CGFloat = 160 @@ -213,14 +214,15 @@ nonisolated enum Metrics { /// of what is waiting, not a second transcript to read. static let steeringQueueMaxHeight: CGFloat = 88 - /// How tall the slash palette may grow before it scrolls. - /// - /// Also a ceiling rather than a height: the palette is measured from the - /// commands that match, so it shrinks to one row as the name is typed - /// and closes when nothing matches. With today's three commands it never - /// reaches this — it exists so a longer list later cannot cover the - /// conversation it was opened over. - static let slashMenuMaxHeight: CGFloat = 220 + /// The Slash palette keeps the compact native-list density it had before + /// moving to SwiftUI. It is exactly as tall as its rows until ten are + /// visible; longer command and option lists scroll in place. + static let slashMenuRowHeight: CGFloat = 34 + static let slashMenuVisibleRows = 10 + + static func slashMenuHeight(rowCount: Int) -> CGFloat { + CGFloat(min(max(0, rowCount), slashMenuVisibleRows)) * slashMenuRowHeight + } /// How much of a collapsed plan is shown before it fades out. /// diff --git a/TFM/Features/AI/ConversationViews.swift b/TFM/Features/AI/ConversationViews.swift index 50e06178..ef0f8eda 100644 --- a/TFM/Features/AI/ConversationViews.swift +++ b/TFM/Features/AI/ConversationViews.swift @@ -971,6 +971,14 @@ struct ComposerView: View { /// confirm what is already true is a row that wastes the reader's time. 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( @@ -1088,7 +1096,9 @@ struct ComposerView: View { modelProviders.flatMap { provider in provider.assistantModels.map { model in ( - argument: "\(provider.id.uuidString.lowercased()):\(model.id)", + argument: ChatSlashCommand.argumentID( + scope: provider.id.uuidString.lowercased(), value: model.id + ), provider: provider, model: model ) @@ -1129,7 +1139,9 @@ struct ComposerView: View { case "model": guard let resolved else { return 0 } let optionID = - "\(resolved.provider.id.uuidString.lowercased()):\(resolved.model.id)" + ChatSlashCommand.argumentID( + scope: resolved.provider.id.uuidString.lowercased(), value: resolved.model.id + ) return completion.selectionIndex(commandID: command.id, optionID: optionID) default: return 0 @@ -1198,8 +1210,8 @@ struct ComposerView: View { } } - /// Completing a name leaves the draft in the field; running a command clears - /// it. The difference is whether there is anything left to choose. + /// Completing a name leaves the command in the field; running it consumes + /// only the trailing slash fragment and preserves any prompt before it. private func acceptSlashRow(_ row: ChatSlashCommandCompletion.Row) { switch row { case .command(let command): @@ -1209,7 +1221,7 @@ struct ComposerView: View { // argument is for whoever wants to type one. let hasOptions = !slashOptions(command).isEmpty let acceptedDraft = ChatSlashCommandCompletion.accepted( - row, takesArgument: hasOptions + row, takesArgument: hasOptions, in: conversation.composer.draft ) conversation.composer.draft = acceptedDraft if hasOptions { @@ -1223,7 +1235,9 @@ struct ComposerView: View { } if !hasOptions { runSlashCommand() } case .option: - conversation.composer.draft = ChatSlashCommandCompletion.accepted(row, takesArgument: false) + conversation.composer.draft = ChatSlashCommandCompletion.accepted( + row, takesArgument: false, in: conversation.composer.draft + ) slashSelection = 0 runSlashCommand() } @@ -1241,9 +1255,16 @@ struct ComposerView: View { 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() + clearSlashDraft(input) onNewChat() return true case "plan": @@ -1253,30 +1274,29 @@ struct ComposerView: View { let question = argumentText(of: input) guard question.isEmpty else { conversation.isPlanning = true - clearSlashDraft() - conversation.composer.draft = question - sendDraft() + conversation.composer.draft = input.leadingText + question + if input.leadingText.isEmpty { sendDraft() } return true } conversation.isPlanning.toggle() case "compact": let instructions = argumentText(of: input) - clearSlashDraft() + clearSlashDraft(input) Task { await app.compact(conversation, instructions: instructions) } return true case "reasoning": - guard case .argument(_, let argument) = input else { return false } + 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, + 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, + guard case .argument(_, _, let argument) = input, let id = UUID(uuidString: argument), let server = app.aiConfiguration.mcpServer(id) else { return false } @@ -1290,28 +1310,27 @@ struct ComposerView: View { guard let selection = input.selection, app.skills.enabledSkills.contains(where: { $0.effectiveName == selection.name }) else { return false } - clearSlashDraft() conversation.composer.armedSkillName = selection.name + conversation.composer.draft = input.leadingText + selection.remainder guard selection.remainder.isEmpty else { - conversation.composer.draft = selection.remainder - sendDraft() + if input.leadingText.isEmpty { sendDraft() } return true } return true default: return false } - clearSlashDraft() + clearSlashDraft(input) return true } private func argumentText(of input: ChatSlashCommandInput) -> String { - guard case .argument(_, let text) = input else { return "" } + guard case .argument(_, _, let text) = input else { return "" } return text.trimmingCharacters(in: .whitespacesAndNewlines) } - private func clearSlashDraft() { - conversation.composer.draft = "" + private func clearSlashDraft(_ input: ChatSlashCommandInput) { + conversation.composer.draft = input.leadingText isSlashMenuDismissed = false slashSelection = 0 } diff --git a/TFM/Features/AI/MarkdownBlockListView.swift b/TFM/Features/AI/MarkdownBlockListView.swift index 9fa646ac..9319c38e 100644 --- a/TFM/Features/AI/MarkdownBlockListView.swift +++ b/TFM/Features/AI/MarkdownBlockListView.swift @@ -4,6 +4,7 @@ import MarkdownParser import MarkdownView import SwiftUI import TermindApplication +import TermindPresentation /// The Markdown entry point used by the transcript. /// @@ -171,93 +172,7 @@ enum MarkdownTypography { // MARK: - Parsing policy -nonisolated enum TermindMarkdownParser { - static let allowedLinkSchemes: Set = ["http", "https", "mailto"] - static let shellLanguages: Set = [ - "bash", "sh", "zsh", "shell", "console", "terminal", - ] - - static func parse(_ source: String) -> [MarkdownBlockNode] { - let parsed = MarkdownParser().parse(MarkdownSoftBreaks.hardened(source)) - return sanitize(parsed.document) - } - - static func sanitize(_ blocks: [MarkdownBlockNode]) -> [MarkdownBlockNode] { - blocks.rewrite { (inline: MarkdownInlineNode) -> [MarkdownInlineNode] in - switch inline { - case .link(let destination, let children): - guard isAllowedLink(destination) else { return children } - return [.link(destination: destination, children: children)] - case .image(_, let children): - // Never fetch a model-chosen URL while rendering a transcript. - // The image's alt text remains readable ordinary prose. - return children - default: - return [inline] - } - } - } - - static func isAllowedLink(_ destination: String) -> Bool { - guard let scheme = URL(string: destination)?.scheme?.lowercased() else { - return false - } - return allowedLinkSchemes.contains(scheme) - } - - static func isActionableShell(language: String?, code: String) -> Bool { - guard let language else { return false } - return shellLanguages.contains(language.lowercased()) - && !normalizedFenceCode(code).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - static func segments( - from blocks: [MarkdownBlockNode], - allowsShellActions: Bool - ) -> [TermindMarkdownBlockSegment] { - var result: [TermindMarkdownBlockSegment] = [] - var markdown: [MarkdownBlockNode] = [] - - func flushMarkdown() { - guard !markdown.isEmpty else { return } - result.append(.markdown(markdown)) - markdown.removeAll(keepingCapacity: true) - } - - for block in blocks { - if allowsShellActions, - case .codeBlock(let language, let code) = block, - isActionableShell(language: language, code: code) - { - flushMarkdown() - result.append( - .shell( - language: language ?? "", - command: normalizedFenceCode(code) - )) - } else { - markdown.append(block) - } - } - flushMarkdown() - return result - } - - /// cmark includes the structural newline immediately before a closing - /// fence. It is not part of the command and must not make a one-line shell - /// suggestion look like a multi-line script to the execution guard. - private static func normalizedFenceCode(_ code: String) -> String { - code.hasSuffix("\n") ? String(code.dropLast()) : code - } - - static func plainText(_ source: String) -> String { - parse(source) - .flatMap(plainText) - .joined(separator: " ") - .split(whereSeparator: \.isWhitespace) - .joined(separator: " ") - } - +extension TermindMarkdownParser { /// A one-line AppKit projection for transient tool titles. It applies the /// same sanitization as the full transcript renderer, preserves inline /// emphasis/code/strikethrough/safe links, and flattens block structure @@ -392,44 +307,6 @@ nonisolated enum TermindMarkdownParser { return result } - private static func plainText(_ block: MarkdownBlockNode) -> [String] { - switch block { - case .paragraph(let content), .heading(_, let content): - [plainText(content)] - case .codeBlock(_, let content): - [content] - case .blockquote(let children): - children.flatMap(plainText) - case .bulletedList(_, let items), .numberedList(_, _, let items): - items.flatMap { $0.children.flatMap(plainText) } - case .taskList(_, let items): - items.flatMap { $0.children.flatMap(plainText) } - case .table(_, let rows): - rows.flatMap { row in row.cells.map { plainText($0.content) } } - case .thematicBreak: - [] - } - } - - private static func plainText(_ inlines: [MarkdownInlineNode]) -> String { - inlines.map { inline in - switch inline { - case .text(let text), .code(let text), .html(let text), .math(let text, _): - text - case .softBreak, .lineBreak: - " " - case .emphasis(let children), .strong(let children), - .strikethrough(let children), .link(_, let children), - .image(_, let children): - plainText(children) - } - }.joined() - } -} - -nonisolated enum TermindMarkdownBlockSegment: Equatable, Sendable { - case markdown([MarkdownBlockNode]) - case shell(language: String, command: String) } // MARK: - Rendered document diff --git a/TFM/Features/AI/SlashCommandMenuView.swift b/TFM/Features/AI/SlashCommandMenuView.swift index cc95b47a..6436d138 100644 --- a/TFM/Features/AI/SlashCommandMenuView.swift +++ b/TFM/Features/AI/SlashCommandMenuView.swift @@ -54,7 +54,7 @@ struct SlashCommandMenuView: View { } private var listHeight: CGFloat { - min(Metrics.Chat.slashMenuMaxHeight, CGFloat(completion.rows.count) * 34) + Metrics.Chat.slashMenuHeight(rowCount: completion.rows.count) } private func scrollSelectionIntoView(using proxy: ScrollViewProxy) { @@ -93,7 +93,12 @@ struct SlashCommandMenuView: View { } .padding(.horizontal, Metrics.Space.s) .padding(.vertical, Metrics.Space.xs) - .frame(maxWidth: .infinity, alignment: .leading) + .frame( + maxWidth: .infinity, + minHeight: Metrics.Chat.slashMenuRowHeight, + maxHeight: Metrics.Chat.slashMenuRowHeight, + alignment: .leading + ) .background( isSelected ? AnyShapeStyle(.tint.opacity(0.16)) : AnyShapeStyle(.clear), in: .rect(cornerRadius: Metrics.Radius.medium) diff --git a/TFM/Features/AI/TranscriptListView.swift b/TFM/Features/AI/TranscriptListView.swift index 96a0666f..868924b6 100644 --- a/TFM/Features/AI/TranscriptListView.swift +++ b/TFM/Features/AI/TranscriptListView.swift @@ -11,17 +11,8 @@ typealias TranscriptEntry = AssistantTranscriptEntry @MainActor @Observable final class TranscriptPresentationState { - static let reasoningTitleDuration: TimeInterval = 3 + let disclosures = AssistantTranscriptPresentationState() - private struct DisclosureState { - var isExpanded: Bool - var readerDidToggle: Bool - var revision: UInt64 - } - private struct ReasoningTitleState { - var revision: String - var expiresAt: Date - } private struct MessageEditState { var isEditing = false var text = "" @@ -29,77 +20,12 @@ final class TranscriptPresentationState { var revision: UInt64 = 0 } - private var disclosureStates: [UUID: DisclosureState] = [:] - private var pendingAutoCollapseIDs: Set = [] - private var reasoningTitles: [UUID: ReasoningTitleState] = [:] private var messageEdits: [UUID: MessageEditState] = [:] private var nextRevision: UInt64 = 1 - func isExpanded(_ id: UUID, default value: Bool = false) -> Bool { - disclosureStates[id]?.isExpanded ?? value - } - func readerDidToggle(_ id: UUID) -> Bool { disclosureStates[id]?.readerDidToggle == true } - func toggleDisclosure(_ id: UUID, default value: Bool = false) { - pendingAutoCollapseIDs.remove(id) - disclosureStates[id] = .init( - isExpanded: !(disclosureStates[id]?.isExpanded ?? value), - readerDidToggle: true, - revision: takeRevision() - ) - } - func followDefault(_ expanded: Bool, for id: UUID) { - guard disclosureStates[id]?.readerDidToggle != true else { return } - pendingAutoCollapseIDs.remove(id) - disclosureStates[id] = .init( - isExpanded: expanded, readerDidToggle: false, revision: takeRevision() - ) - } - func deferAutoCollapse(_ id: UUID) { - guard disclosureStates[id]?.readerDidToggle != true else { return } - pendingAutoCollapseIDs.insert(id) - disclosureStates[id] = .init( - isExpanded: true, readerDidToggle: false, revision: takeRevision() - ) - } - func hasPendingAutoCollapse(_ id: UUID) -> Bool { - pendingAutoCollapseIDs.contains(id) - } - @discardableResult - func completeAutoCollapse(_ id: UUID) -> Bool { - guard pendingAutoCollapseIDs.remove(id) != nil, - disclosureStates[id]?.readerDidToggle != true - else { return false } - disclosureStates[id] = .init( - isExpanded: false, readerDidToggle: false, revision: takeRevision() - ) - return true - } - - /// Returns an absolute deadline so remounting or measuring a row cannot - /// restart its transient reasoning title. A changed Markdown revision does - /// deliberately receive a fresh three-second window. - func reasoningTitleDeadline( - for id: UUID, - revision: String, - now: Date = .now - ) -> Date { - if let state = reasoningTitles[id], state.revision == revision { - return state.expiresAt - } - let deadline = now.addingTimeInterval(Self.reasoningTitleDuration) - reasoningTitles[id] = .init(revision: revision, expiresAt: deadline) - return deadline - } func prune(validTimelineIDs: Set) { - let staleDisclosures = disclosureStates.keys.filter { !validTimelineIDs.contains($0) } - let staleReasoningTitles = reasoningTitles.keys.filter { !validTimelineIDs.contains($0) } - let staleEdits = messageEdits.keys.filter { !validTimelineIDs.contains($0) } - guard !staleDisclosures.isEmpty || !staleReasoningTitles.isEmpty || !staleEdits.isEmpty - else { return } - for id in staleDisclosures { disclosureStates[id] = nil } - pendingAutoCollapseIDs.formIntersection(validTimelineIDs) - for id in staleReasoningTitles { reasoningTitles[id] = nil } - for id in staleEdits { messageEdits[id] = nil } + disclosures.prune(validTimelineIDs: validTimelineIDs) + for id in messageEdits.keys where !validTimelineIDs.contains(id) { messageEdits[id] = nil } } func isEditingMessage(_ id: UUID) -> Bool { messageEdits[id]?.isEditing == true } @@ -132,8 +58,8 @@ final class TranscriptPresentationState { messageEdits[id] = state } func layoutRevision(for ids: Set) -> UInt64 { - ids.reduce(0) { result, id in - result &+ (disclosureStates[id]?.revision ?? 0) &+ (messageEdits[id]?.revision ?? 0) + ids.reduce(disclosures.layoutRevision(for: ids)) { result, id in + result &+ (messageEdits[id]?.revision ?? 0) } } private func takeRevision() -> UInt64 { @@ -629,22 +555,10 @@ struct TranscriptListView: NSViewRepresentable { in view: NativeListView, presentation: TranscriptPresentationState ) { - let previous = Dictionary(uniqueKeysWithValues: old.map { ($0.id, $0) }) - for entry in new { - guard case .work(let group) = entry.content, - group.run.state == .completed, - !group.appearsAfterAnswer, - case .work(let oldGroup)? = previous[entry.id]?.content, - oldGroup.run.state != .completed, - !presentation.readerDidToggle(entry.id) - else { continue } - - if rowIntersectsViewport(entry.id, in: view) { - presentation.deferAutoCollapse(entry.id) - } else { - presentation.followDefault(false, for: entry.id) - } - } + presentation.disclosures.prepareWorkCompletionTransitions( + from: old, to: new, + visibleIDs: Set(old.filter { rowIntersectsViewport($0.id, in: view) }.map(\.id)) + ) } @objc private func viewportDidChange() { @@ -664,9 +578,9 @@ struct TranscriptListView: NSViewRepresentable { private func collapsePendingWorkOutsideViewport() { guard let view = listView, let presentation else { return } var collapsed: [UUID] = [] - for entry in content where presentation.hasPendingAutoCollapse(entry.id) { + for entry in content where presentation.disclosures.hasPendingAutoCollapse(entry.id) { guard !rowIntersectsViewport(entry.id, in: view), - presentation.completeAutoCollapse(entry.id) + presentation.disclosures.completeAutoCollapse(entry.id) else { continue } collapsed.append(entry.id) } diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 15803a86..2bfb8ba8 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -6,6 +6,7 @@ import SwiftUI import TermindApplication import TermindDomain import TermindPresentation +import TermindRemoteInfrastructure @MainActor final class TranscriptMarkdownCache { @@ -746,9 +747,14 @@ private enum NativeTranscriptFactory { return view } - private static func tool(_ card: AgentToolCard, context: NativeTranscriptContext) -> NSView { + private static func tool( + _ card: AgentToolCard, + defaultExpanded: Bool = false, + context: NativeTranscriptContext + ) -> NSView { let stack = NativeStack.vertical() - let expanded = context.presentation.isExpanded(card.id) + let disclosureID = AssistantTranscriptDisclosureID.tool(card.id) + let expanded = context.presentation.disclosures.isExpanded(disclosureID, default: defaultExpanded) let host = card.invocation.targetHostID.flatMap(context.actions.hostName) let title = AgentToolPresentation.summary( for: card, @@ -770,7 +776,7 @@ private enum NativeTranscriptFactory { header.alphaValue = card.state == .interrupted ? 0.5 : 1 header.toolTip = expanded ? String(localized: "Hide details") : String(localized: "Show details") header.handler = { - context.presentation.toggleDisclosure(card.id) + context.presentation.disclosures.toggleDisclosure(disclosureID, default: defaultExpanded) context.invalidate(context.entryID, false) } stack.addArrangedSubview(header) @@ -792,7 +798,7 @@ private enum NativeTranscriptFactory { )) } if let projected = AgentToolPresentation.detail( - for: card, + for: card, presenter: TermindAgentTools.presenter(for: card.descriptor), locale: context.configuration.locale ) { for item in projected.items { @@ -821,7 +827,7 @@ private enum NativeTranscriptFactory { appearsAfterAnswer: group.appearsAfterAnswer, isCompact: context.configuration.isCompact ) - let expanded = context.presentation.isExpanded(group.id, default: defaultExpanded) + let expanded = context.presentation.disclosures.isExpanded(.work(group.id), default: defaultExpanded) let titleProvider = { AgentWorkPresentation.title( for: group.run.state, @@ -842,7 +848,7 @@ private enum NativeTranscriptFactory { ) header.toolTip = expanded ? String(localized: "Hide work") : String(localized: "Show work") header.handler = { - context.presentation.toggleDisclosure(group.id, default: defaultExpanded) + context.presentation.disclosures.toggleDisclosure(.work(group.id), default: defaultExpanded) context.invalidate(context.entryID, false) } stack.addArrangedSubview(header) @@ -900,8 +906,12 @@ private enum NativeTranscriptFactory { _ batch: AgentToolBatch, context: NativeTranscriptContext ) -> NSView { + if batch.cards.count == 1, let card = batch.cards.first { + return tool(card, defaultExpanded: true, context: context) + } let stack = NativeStack.vertical(spacing: Metrics.Space.s) - let expanded = context.presentation.isExpanded(batch.id) + let disclosureID = AssistantTranscriptDisclosureID.toolBatch(batch.id) + let expanded = context.presentation.disclosures.isExpanded(disclosureID) let unfinished = batch.cards.filter { !$0.state.isTerminal } let fallbackTitle: String if unfinished.isEmpty { @@ -922,7 +932,7 @@ private enum NativeTranscriptFactory { let reasoningDeadline = transientReasoning.flatMap { reasoning in context.measurement ? nil - : context.presentation.reasoningTitleDeadline( + : context.presentation.disclosures.reasoningTitleDeadline( for: batch.id, revision: reasoning.revision ) @@ -942,7 +952,7 @@ private enum NativeTranscriptFactory { ) header.toolTip = expanded ? String(localized: "Hide tools") : String(localized: "Show tools") header.handler = { - context.presentation.toggleDisclosure(batch.id) + context.presentation.disclosures.toggleDisclosure(disclosureID) context.invalidate(context.entryID, false) } stack.addArrangedSubview(header) @@ -973,7 +983,7 @@ private enum NativeTranscriptFactory { right: Metrics.Space.l ) container.setContent(stack) - let expanded = context.presentation.isExpanded(card.id) + let expanded = context.presentation.disclosures.isExpanded(.plan(card.id)) let symbol: String let tint: NSColor let outcome: String? @@ -1005,7 +1015,7 @@ private enum NativeTranscriptFactory { ) header.toolTip = expanded ? String(localized: "Hide the plan") : String(localized: "Show the plan") header.handler = { - context.presentation.toggleDisclosure(card.id) + context.presentation.disclosures.toggleDisclosure(.plan(card.id)) context.invalidate(context.entryID, false) } stack.addArrangedSubview(header) @@ -1027,7 +1037,7 @@ private enum NativeTranscriptFactory { let preview = NativePlanPreview(content: body, expanded: expanded) preview.handler = { guard !expanded else { return } - context.presentation.toggleDisclosure(card.id) + context.presentation.disclosures.toggleDisclosure(.plan(card.id)) context.invalidate(context.entryID, false) } indented.addArrangedSubview(preview) @@ -1087,7 +1097,7 @@ private enum NativeTranscriptFactory { failure == nil ? String(localized: "Compacting context") : String(localized: "Couldn’t compact context") - let expanded = context.presentation.isExpanded(activity.id) + let expanded = context.presentation.disclosures.isExpanded(.compactionActivity(activity.id)) let header = NativeDisclosureHeader( symbol: failure == nil ? "doc.text.magnifyingglass" : "exclamationmark.triangle", title: title, @@ -1105,7 +1115,7 @@ private enum NativeTranscriptFactory { : String(localized: "Show failure details") header.handler = { guard failure != nil else { return } - context.presentation.toggleDisclosure(activity.id) + context.presentation.disclosures.toggleDisclosure(.compactionActivity(activity.id)) context.invalidate(context.entryID, false) } stack.addArrangedSubview(header) @@ -1485,12 +1495,7 @@ private enum NativeTranscriptFactory { } private static func toolColor(_ state: AgentToolCard.State) -> NSColor { - switch state { - case .denied, .failed: .systemRed - case .reviewing, .waitingForApproval, .waitingForUser: .systemOrange - case .completed: .systemGreen - default: .secondaryLabelColor - } + batchColor(AgentToolPresentation.tint(for: state)) } private static func batchColor(_ tint: AgentToolPresentation.BatchTint) -> NSColor { @@ -2551,7 +2556,7 @@ private final class NativeActivityLabel: NativeLabel { guard !measurement, !reduceMotion else { return } if alternatives.count > 1 { timer = Timer.scheduledTimer( - timeInterval: 2.8, + timeInterval: AssistantTranscriptStyle.activityTextDuration, target: self, selector: #selector(advanceText), userInfo: nil, @@ -2609,9 +2614,9 @@ private nonisolated final class NativeScanningMaskLayer: CALayer { context.setFillColor(CGColor(gray: 1, alpha: 1)) context.fill(bounds) - let bandWidth = min(max(bounds.width * 0.32, 40), 96) + let bandWidth = AssistantTranscriptStyle.scanBandWidth(for: bounds.width) let origin = -bandWidth + (bounds.width + bandWidth) * progress - let alphas: [CGFloat] = [0, 0.34, 0.82, 1, 0.82, 0.34, 0] + let alphas = AssistantTranscriptStyle.scanAlphas.map { CGFloat($0) } guard let gradient = CGGradient( colorsSpace: CGColorSpaceCreateDeviceRGB(), @@ -2643,7 +2648,7 @@ private func installNativeScanningMask(on view: NSView) { let animation = CABasicAnimation(keyPath: #keyPath(NativeScanningMaskLayer.progress)) animation.fromValue = 0 animation.toValue = 1 - animation.duration = 1.55 + animation.duration = AssistantTranscriptStyle.scanDuration animation.repeatCount = .infinity animation.timingFunction = CAMediaTimingFunction(name: .linear) mask.add(animation, forKey: "scan") diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index c55a549d..6e513e6e 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -622,7 +622,6 @@ struct SFTPBrowserView: View { .disabled(sftpSession.status != .connected) Button("Upload", systemImage: MenuSymbol.upload) { chooseUploadItems() } .disabled(sftpSession.status != .connected) - Divider() Button("Refresh", systemImage: MenuSymbol.refresh) { sftpSession.reloadNow() } .disabled(sftpSession.status != .connected || sftpSession.isLoading) } @@ -977,11 +976,11 @@ struct SFTPBrowserView: View { } .disabled(!sftpSession.allowsRemoteMutations) Divider() - Button("New Folder") { newFolderPresented = true } + Button("New Folder", systemImage: MenuSymbol.newFolder) { newFolderPresented = true } .disabled(sftpSession.status != .connected) - Button("Upload") { chooseUploadItems() } + Button("Upload", systemImage: MenuSymbol.upload) { chooseUploadItems() } .disabled(sftpSession.status != .connected) - Button("Refresh") { sftpSession.reloadNow() } + Button("Refresh", systemImage: MenuSymbol.refresh) { sftpSession.reloadNow() } .disabled(sftpSession.status != .connected || sftpSession.isLoading) } diff --git a/TFM/Features/SSHImport/SSHImportView.swift b/TFM/Features/SSHImport/SSHImportView.swift index c9f85624..cb48cb89 100644 --- a/TFM/Features/SSHImport/SSHImportView.swift +++ b/TFM/Features/SSHImport/SSHImportView.swift @@ -1,6 +1,7 @@ import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation struct SSHImportView: View { @Environment(AppModel.self) private var app diff --git a/TFM/Features/Workspaces/WorkspaceContentView.swift b/TFM/Features/Workspaces/WorkspaceContentView.swift index 81f65c45..c4a1192d 100644 --- a/TFM/Features/Workspaces/WorkspaceContentView.swift +++ b/TFM/Features/Workspaces/WorkspaceContentView.swift @@ -2,6 +2,7 @@ import GlyphixTextFx import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation import UniformTypeIdentifiers import struct TermindDomain.Host diff --git a/TFM/UI/Components/DropTargetOverlay.swift b/TFM/UI/Components/DropTargetOverlay.swift deleted file mode 100644 index 32913d45..00000000 --- a/TFM/UI/Components/DropTargetOverlay.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI - -nonisolated struct DropTargetPresentation: Equatable, Sendable { - nonisolated enum Tone: Equatable, Sendable { - case accent - case unavailable - } - - let title: String - let detail: String - let systemImage: String - var tone: Tone = .accent -} - -/// One unmistakable drop affordance for every full-surface destination. -/// -/// The content under it remains visible enough to retain place, while the -/// moving dashed border and the compact glass card make the pending action -/// legible over a terminal, a file list, or a transcript. -struct DropTargetOverlay: View { - let presentation: DropTargetPresentation - - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var animatesSymbol = false - - private var tint: Color { - presentation.tone == .unavailable ? .orange : .accentColor - } - - var body: some View { - ZStack { - tint.opacity(0.09) - - RoundedRectangle(cornerRadius: Metrics.Radius.large) - .stroke( - tint, - style: StrokeStyle(lineWidth: 2, dash: [7, 5]) - ) - .padding(Metrics.Space.m) - - VStack(spacing: Metrics.Space.s) { - Image(systemName: presentation.systemImage) - .font(.system(size: 24, weight: .semibold)) - .foregroundStyle(tint) - .symbolEffect( - .bounce.up, - options: reduceMotion ? .nonRepeating : .repeating.speed(0.55), - value: animatesSymbol - ) - Text(presentation.title) - .font(.headline) - Text(presentation.detail) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(2) - .truncationMode(.middle) - } - .multilineTextAlignment(.center) - .padding(.horizontal, Metrics.Space.xl) - .padding(.vertical, Metrics.Space.l) - .termindTransientOverlaySurface() - } - .transition( - reduceMotion - ? .opacity - : .opacity.combined(with: .scale(scale: 0.96)) - ) - .onAppear { animatesSymbol = true } - } -} diff --git a/TFMTests/AgentPersistenceTests.swift b/TFMTests/AgentPersistenceTests.swift index 3e1e8849..e532c55a 100644 --- a/TFMTests/AgentPersistenceTests.swift +++ b/TFMTests/AgentPersistenceTests.swift @@ -71,8 +71,10 @@ struct AgentPersistenceTests { ) let locale = Locale(identifier: "en") #expect( - AgentToolPresentation.detail(for: restored, locale: locale) - == AgentToolPresentation.detail(for: live, locale: locale) + AgentToolPresentation.detail( + for: restored, presenter: TermindAgentTools.presenter(for: restored.descriptor), locale: locale) + == AgentToolPresentation.detail( + for: live, presenter: TermindAgentTools.presenter(for: live.descriptor), locale: locale) ) #expect(restored.descriptor.presentation?.presenterID == "builtin.web_search") } diff --git a/TFMTests/AgentRuntimeTests.swift b/TFMTests/AgentRuntimeTests.swift index f59b6cda..b232550c 100644 --- a/TFMTests/AgentRuntimeTests.swift +++ b/TFMTests/AgentRuntimeTests.swift @@ -989,7 +989,7 @@ private func hangingTool(_ gate: AsyncGate, onCancel: Recorder? = nil) -> AnyAge ) { invocation, _ in await gate.begin() do { - try await Task.sleep(for: .seconds(5)) + try await Task.sleep(for: .seconds(30)) } catch { await onCancel?.append("hang") throw error diff --git a/TFMTests/AgentToolPresentationTests.swift b/TFMTests/AgentToolPresentationTests.swift index b0541364..3ae1205f 100644 --- a/TFMTests/AgentToolPresentationTests.swift +++ b/TFMTests/AgentToolPresentationTests.swift @@ -52,7 +52,8 @@ struct AgentToolPresentationTests { ]).encodedString ) - let detail = try #require(AgentToolPresentation.detail(for: card)) + let detail = try #require( + AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor))) #expect( detail.items == [ .field(.init(label: "Question", value: "Storage — Which format should we use?")), @@ -103,7 +104,8 @@ struct AgentToolPresentationTests { card.state = .completed card.result = AgentToolResult(callID: "call", content: result.encodedString) - let detail = try #require(AgentToolPresentation.detail(for: card)) + let detail = try #require( + AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor))) guard case .groupedList(let listing) = try #require(detail.items.first) else { Issue.record("Expected a grouped host listing") return @@ -142,7 +144,8 @@ struct AgentToolPresentationTests { ]).encodedString ) - let detail = try #require(AgentToolPresentation.detail(for: card)) + let detail = try #require( + AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor))) guard case .groupedList(let listing) = try #require(detail.items.first) else { Issue.record("Expected a grouped host listing") return @@ -158,7 +161,8 @@ struct AgentToolPresentationTests { ]).encodedString var builtIn = makeCard(name: "future_tool", target: .local) builtIn.result = AgentToolResult(callID: "call", content: payload) - let builtInDetail = try #require(AgentToolPresentation.detail(for: builtIn)) + let builtInDetail = try #require( + AgentToolPresentation.detail(for: builtIn, presenter: TermindAgentTools.presenter(for: builtIn.descriptor))) #expect( builtInDetail.items == [ .field(.init(label: "Count", value: "2")), @@ -173,7 +177,8 @@ struct AgentToolPresentationTests { ) ) mcp.result = AgentToolResult(callID: "call", content: payload) - let mcpDetail = try #require(AgentToolPresentation.detail(for: mcp)) + let mcpDetail = try #require( + AgentToolPresentation.detail(for: mcp, presenter: TermindAgentTools.presenter(for: mcp.descriptor))) guard case .text(let block) = try #require(mcpDetail.items.first) else { Issue.record("Expected MCP JSON text") return @@ -198,7 +203,8 @@ struct AgentToolPresentationTests { ]).encodedString ) - let detail = try #require(AgentToolPresentation.detail(for: card)) + let detail = try #require( + AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor))) #expect( detail.items == [ .field(.init(label: "Status", value: "200")), @@ -390,7 +396,9 @@ struct AgentToolPresentationTests { for (name, target, arguments, result) in cases { var card = makeCard(name: name, target: target, arguments: arguments) card.result = AgentToolResult(callID: "call", content: result.encodedString) - let detail = try #require(AgentToolPresentation.detail(for: card), "Missing \(name)") + let detail = try #require( + AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor)), + "Missing \(name)") let rendered = String(describing: detail) #expect(!rendered.contains("{"), "\(name) exposed JSON") #expect(!rendered.contains("}"), "\(name) exposed JSON") @@ -422,7 +430,8 @@ struct AgentToolPresentationTests { ]).encodedString ) - let detail = try #require(AgentToolPresentation.detail(for: card)) + let detail = try #require( + AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor))) let rendered = String(describing: detail) #expect(!rendered.contains("{")) #expect(!rendered.contains("}")) @@ -458,7 +467,8 @@ struct AgentToolPresentationTests { ]).encodedString ) - let detail = try #require(AgentToolPresentation.detail(for: card)) + let detail = try #require( + AgentToolPresentation.detail(for: card, presenter: TermindAgentTools.presenter(for: card.descriptor))) let rendered = String(describing: detail) #expect(rendered.contains("nginx.conf:12")) #expect(rendered.contains("listen 443 ssl;")) @@ -622,11 +632,10 @@ struct AgentToolPresentationTests { let full = "one\ntwo\nthree" let preview = AgentToolPresentation.preview(full) - #expect( - preview - == .init( - displayed: full, full: full, isShortened: false, omittedCharacters: 0 - )) + #expect(preview.displayed == full) + #expect(preview.full == full) + #expect(!preview.isShortened) + #expect(preview.omittedCharacters == 0) } @Test @@ -693,8 +702,12 @@ struct AgentToolPresentationTests { legacy.descriptor.presentation = nil #expect( - AgentToolPresentation.detail(for: restored, locale: Locale(identifier: "en")) - == AgentToolPresentation.detail(for: legacy, locale: Locale(identifier: "en")) + AgentToolPresentation.detail( + for: restored, presenter: TermindAgentTools.presenter(for: restored.descriptor), + locale: Locale(identifier: "en")) + == AgentToolPresentation.detail( + for: legacy, presenter: TermindAgentTools.presenter(for: legacy.descriptor), + locale: Locale(identifier: "en")) ) } diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 3c54d20a..9e769a69 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -112,6 +112,9 @@ struct ArchitectureBoundaryTests { contentsOf: sourceRoot.appending(path: "../Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift"), encoding: .utf8) #expect(!model.contains("hosts.sshConnectionConfiguration(")) + #expect(model.contains("workers[server.id] = Task.detached")) + #expect(model.contains("private nonisolated func run(")) + #expect(model.contains("try await beginRunAttempt(")) let preparation = try String( contentsOf: sourceRoot.appending( path: "../Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift"), @@ -666,6 +669,54 @@ struct ArchitectureBoundaryTests { ]) } + @Test + func fullSurfaceDropOverlayHasOneSharedPresentationOwner() throws { + let sharedPath = sourceRoot.appending( + path: "../Frameworks/TermindPresentation/DropTargetOverlay.swift") + let shared = try String(contentsOf: sharedPath, encoding: .utf8) + + #expect(shared.contains("public struct DropTargetOverlay")) + #expect(shared.contains("public nonisolated struct DropTargetPresentation")) + #expect( + !FileManager.default.fileExists( + atPath: sourceRoot.appending(path: "UI/Components/DropTargetOverlay.swift").path + ) + ) + + for path in [ + "Features/AI/ConversationTranscriptView.swift", + "../TFI/Features/AI/MobileAssistantConversation.swift", + "../TFI/Features/Workspaces/MobileWorkspaceView.swift", + ] { + let consumer = try String( + contentsOf: sourceRoot.appending(path: path), encoding: .utf8) + #expect(consumer.contains("DropTargetOverlay("), "\(path) does not reuse the shared overlay") + } + } + + @Test + func sftpUploadTargetsTheCurrentOpenDirectoryAcrossPlatforms() throws { + let desktop = try String( + contentsOf: sourceRoot.appending(path: "Features/SFTP/SFTPSessionModel.swift"), + encoding: .utf8 + ) + let mobile = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Features/Workspaces/MobileWorkspace.swift"), + encoding: .utf8 + ) + let desktopUpload = try #require( + desktop.components(separatedBy: "func prepareUpload").last? + .components(separatedBy: "func prepareDownload").first + ) + let mobileDestination = try #require( + mobile.components(separatedBy: "var uploadDestination").last? + .components(separatedBy: "var uploadTargetProblem").first + ) + + #expect(desktopUpload.contains("destination: path")) + #expect(mobileDestination.contains("return sftpSession.path")) + } + @Test func swiftDataModelsHaveOneSharedOwner() throws { for directory in [ diff --git a/TFMTests/ChatSlashCommandTests.swift b/TFMTests/ChatSlashCommandTests.swift index e383fe0e..05baa7eb 100644 --- a/TFMTests/ChatSlashCommandTests.swift +++ b/TFMTests/ChatSlashCommandTests.swift @@ -28,6 +28,17 @@ struct ChatSlashCommandTests { ), ] + @Test + func commandsCanOptOutOfTheMobileCatalog() { + let archive = ChatSlashCommand( + id: "archive", displayName: "Archive", summary: "Archive this chat", + icon: .symbol("archivebox"), isShownOnMobile: false + ) + + #expect(!archive.isShownOnMobile) + #expect(!commands.contains { !$0.isShownOnMobile }) + } + private func options(_ command: ChatSlashCommand) -> [ChatSlashCommand.Option] { guard command.id == "reasoning" else { return [] } return [ @@ -41,28 +52,42 @@ struct ChatSlashCommandTests { @Test func aBareSlashIsACommandBeingNamed() { - #expect(ChatSlashCommandInput.parse("/") == .naming("")) - #expect(ChatSlashCommandInput.parse("/comp") == .naming("comp")) + #expect(ChatSlashCommandInput.parse("/") == .naming(leadingText: "", partial: "")) + #expect(ChatSlashCommandInput.parse("/comp") == .naming(leadingText: "", partial: "comp")) } @Test func aSpaceSeparatesTheNameFromItsArgument() { #expect( ChatSlashCommandInput.parse("/compact keep the nginx findings") - == .argument(name: "compact", text: "keep the nginx findings") + == .argument(leadingText: "", name: "compact", text: "keep the nginx findings") ) // The trailing space is what advances the palette to the options, so an // empty argument has to be distinguishable from no argument at all. - #expect(ChatSlashCommandInput.parse("/reasoning ") == .argument(name: "reasoning", text: "")) + #expect( + ChatSlashCommandInput.parse("/reasoning ") + == .argument(leadingText: "", name: "reasoning", text: "") + ) } @Test - func proseIsNotACommand() { - #expect(ChatSlashCommandInput.parse("check /etc/nginx for the include") == nil) + func textWithoutASlashIsNotACommand() { #expect(ChatSlashCommandInput.parse("what does df say") == nil) #expect(ChatSlashCommandInput.parse("") == nil) } + @Test + func theLastSlashCanOpenCommandsAnywhereInTheDraft() { + #expect( + ChatSlashCommandInput.parse("check the service /comp") + == .naming(leadingText: "check the service ", partial: "comp") + ) + #expect( + ChatSlashCommandInput.parse("first /plan then /reasoning hi") + == .argument(leadingText: "first /plan then ", name: "reasoning", text: "hi") + ) + } + /// A path pasted as the whole draft still parses as a name — it is one /// token beginning with a slash — but it matches no command, so the palette /// stays shut and the draft sends as a message. @@ -75,11 +100,13 @@ struct ChatSlashCommandTests { #expect(completion.isEmpty) } - /// Once a draft has a second line it is prose that happened to start with a - /// slash, and losing it to a command would be the worst outcome available. @Test - func aMultiLineDraftIsNeverACommand() { + func aCommandFragmentCannotCrossALineButCanStartOnTheLastLine() { #expect(ChatSlashCommandInput.parse("/compact\nand also check disk") == nil) + #expect( + ChatSlashCommandInput.parse("check disk\nthen /comp") + == .naming(leadingText: "check disk\nthen ", partial: "comp") + ) } // MARK: - Completion @@ -89,7 +116,7 @@ struct ChatSlashCommandTests { #expect(commands[1].name == "/plan") #expect( ChatSlashCommandCompletion.accepted( - .command(commands[1]), takesArgument: false + .command(commands[1]), takesArgument: false, in: "/pl" ) == "/plan" ) } @@ -173,6 +200,18 @@ struct ChatSlashCommandTests { #expect(completion.selectionIndex(commandID: "model", optionID: providerB) == 1) } + @Test + func opaqueArgumentsDoNotIntroduceANewerSlashForPathLikeModelIDs() { + let argument = ChatSlashCommand.argumentID( + scope: UUID().uuidString.lowercased(), value: "vendor/model-preview" + ) + + #expect(!argument.contains("/")) + #expect( + ChatSlashCommandInput.parse("draft /model \(argument)")?.name == "model" + ) + } + @Test func anUnavailableOrFilteredPreferredOptionFallsBackToTheFirstRow() { let completion = ChatSlashCommandCompletion.rows( @@ -200,8 +239,8 @@ struct ChatSlashCommandTests { func acceptingANameThatTakesAnArgumentLeavesATrailingSpace() { let row = ChatSlashCommandCompletion.Row.command(commands[2]) - #expect(ChatSlashCommandCompletion.accepted(row, takesArgument: true) == "/reasoning ") - #expect(ChatSlashCommandCompletion.accepted(row, takesArgument: false) == "/reasoning") + #expect(ChatSlashCommandCompletion.accepted(row, takesArgument: true, in: "/rea") == "/reasoning ") + #expect(ChatSlashCommandCompletion.accepted(row, takesArgument: false, in: "/rea") == "/reasoning") } @Test @@ -211,10 +250,25 @@ struct ChatSlashCommandTests { ) #expect( - ChatSlashCommandCompletion.accepted(row, takesArgument: false) == "/reasoning high" + ChatSlashCommandCompletion.accepted(row, takesArgument: false, in: "/reasoning h") + == "/reasoning high" ) } + @Test + func acceptingACompletionOnlyReplacesTheTrailingSlashFragment() { + let row = ChatSlashCommandCompletion.Row.option( + commands[2], ChatSlashCommand.Option(id: "high", label: "High") + ) + + #expect( + ChatSlashCommandCompletion.accepted( + row, takesArgument: false, in: "keep this prompt /reasoning h" + ) == "keep this prompt /reasoning high" + ) + #expect(ChatSlashCommandInput.parse("keep this prompt /reasoning high")?.leadingText == "keep this prompt ") + } + // MARK: - Named arguments /// `/skill ` arms it, `/skill ` arms it and asks in the diff --git a/TFMTests/ListViewMigrationTests.swift b/TFMTests/ListViewMigrationTests.swift index 255dafae..05d77b69 100644 --- a/TFMTests/ListViewMigrationTests.swift +++ b/TFMTests/ListViewMigrationTests.swift @@ -139,6 +139,14 @@ struct ListViewMigrationTests { #expect(Metrics.TransferCenter.listHeight(itemCount: 20) == 420) } + @Test + func slashMenuUsesExactCompactRowsAndScrollsAfterTen() { + #expect(Metrics.Chat.slashMenuHeight(rowCount: 0) == 0) + #expect(Metrics.Chat.slashMenuHeight(rowCount: 3) == 102) + #expect(Metrics.Chat.slashMenuHeight(rowCount: 10) == 340) + #expect(Metrics.Chat.slashMenuHeight(rowCount: 20) == 340) + } + @Test func sidebarHistoryKeepsIdentityWhileInlineEditingChanges() { let entry = CommandHistoryEntry(hostID: UUID(), command: "pwd") diff --git a/TFMTests/TranscriptListViewTests.swift b/TFMTests/TranscriptListViewTests.swift index f9679a50..8e3a8a6c 100644 --- a/TFMTests/TranscriptListViewTests.swift +++ b/TFMTests/TranscriptListViewTests.swift @@ -106,67 +106,6 @@ struct TranscriptListUpdatePlanTests { @MainActor @Suite struct TranscriptPresentationStateTests { - @Test - func aReaderToggleSurvivesDefaultStateTransitions() { - let id = UUID() - let state = TranscriptPresentationState() - - #expect(state.isExpanded(id, default: true)) - state.toggleDisclosure(id, default: true) - #expect(!state.isExpanded(id, default: true)) - #expect(state.readerDidToggle(id)) - - state.followDefault(true, for: id) - #expect(!state.isExpanded(id, default: true)) - } - - @Test - func untouchedDisclosureFollowsItsRuntimeDefault() { - let id = UUID() - let state = TranscriptPresentationState() - - state.followDefault(true, for: id) - #expect(state.isExpanded(id)) - #expect(!state.readerDidToggle(id)) - } - - @Test - func deferredAutoCollapseWaitsForTheCoordinatorAndRespectsReaderOverride() { - let id = UUID() - let state = TranscriptPresentationState() - - state.deferAutoCollapse(id) - #expect(state.isExpanded(id)) - #expect(state.hasPendingAutoCollapse(id)) - #expect(state.completeAutoCollapse(id)) - #expect(!state.isExpanded(id)) - - state.deferAutoCollapse(id) - state.toggleDisclosure(id, default: true) - #expect(!state.hasPendingAutoCollapse(id)) - #expect(!state.completeAutoCollapse(id)) - } - - @Test - func reasoningDeadlineIsAbsoluteAndNewMarkdownResetsIt() { - let id = UUID() - let state = TranscriptPresentationState() - let start = Date(timeIntervalSince1970: 1_000) - - let first = state.reasoningTitleDeadline( - for: id, revision: "first", now: start - ) - #expect(first == start.addingTimeInterval(3)) - #expect( - state.reasoningTitleDeadline( - for: id, revision: "first", now: start.addingTimeInterval(2) - ) == first) - #expect( - state.reasoningTitleDeadline( - for: id, revision: "second", now: start.addingTimeInterval(2) - ) == start.addingTimeInterval(5)) - } - @Test func messageDraftSurvivesRowRecyclingAndCanBePruned() { let id = UUID() @@ -390,8 +329,8 @@ struct TranscriptListViewBridgeTests { presentation: presentation ) - #expect(presentation.isExpanded(id)) - #expect(presentation.hasPendingAutoCollapse(id)) + #expect(presentation.disclosures.isExpanded(.work(id))) + #expect(presentation.disclosures.hasPendingAutoCollapse(id)) let expandedFrame = listView.rectForRow(with: id) listView.setContentOffset( @@ -400,7 +339,7 @@ struct TranscriptListViewBridgeTests { ) listView.layoutSubtreeIfNeeded() await Task.yield() - #expect(presentation.isExpanded(id), "one visible point must defer collapse") + #expect(presentation.disclosures.isExpanded(.work(id)), "one visible point must defer collapse") listView.setContentOffset( CGPoint(x: 0, y: listView.maximumContentOffset.y), @@ -409,8 +348,8 @@ struct TranscriptListViewBridgeTests { listView.layoutSubtreeIfNeeded() await Task.yield() - #expect(!presentation.isExpanded(id)) - #expect(!presentation.hasPendingAutoCollapse(id)) + #expect(!presentation.disclosures.isExpanded(.work(id))) + #expect(!presentation.disclosures.hasPendingAutoCollapse(id)) } @Test @@ -1085,7 +1024,7 @@ struct TranscriptListViewBridgeTests { listView.layoutSubtreeIfNeeded() let collapsedHeight = listView.rectForRow(with: plan.id).height - presentation.toggleDisclosure(plan.id) + presentation.disclosures.toggleDisclosure(.plan(plan.id)) coordinator.refreshDisplayedRow(plan.id, in: listView) listView.layoutSubtreeIfNeeded() diff --git a/TFMTests/TranscriptRenderingTests.swift b/TFMTests/TranscriptRenderingTests.swift new file mode 100644 index 00000000..9911fe6d --- /dev/null +++ b/TFMTests/TranscriptRenderingTests.swift @@ -0,0 +1,79 @@ +import AppKit +import Foundation +import SwiftUI +import TermindApplication +import TermindPresentation +import Testing + +@testable import Termind + +@Suite(.serialized) @MainActor +struct TranscriptRenderingTests { + @Test func renderTranscriptWorkStates() async throws { + guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } + let directory = FileManager.default.temporaryDirectory.appending(path: "assistant-screenshots") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + for language in ["en", "zh-Hans"] { + for phase in AssistantTranscriptPreview.Phase.allCases { + for dark in [false, true] { + let group = AssistantTranscriptPreview.work( + language: language == "en" ? .english : .simplifiedChinese, phase: phase + ) + let presentation = TranscriptPresentationState() + presentation.disclosures.followDefault(true, for: .work(group.id)) + for item in group.items { + switch item { + case .tool(let card): + presentation.disclosures.followDefault(true, for: .tool(card.id)) + case .tools(let batch): + presentation.disclosures.followDefault(true, for: .toolBatch(batch.id)) + for card in batch.cards { + presentation.disclosures.followDefault(true, for: .tool(card.id)) + } + case .reasoning, .message: + break + } + } + let row = TranscriptNativeRow.make(for: .work) + row.configure( + entry: .init(id: group.id, content: .work(group)), proposedWidth: 840, + configuration: .init( + isCompact: false, showsTerminalActions: false, isConversationStreaming: phase != .results, + locale: Locale(identifier: language), reduceMotion: false, + colorScheme: dark ? .dark : .light + ), actions: .init(), presentation: presentation, + markdownCache: TranscriptMarkdownCache(limit: 48), + measurement: false, invalidate: { _, _ in } + ) + let height = ceil(row.measuredHeight(for: 840)) + row.translatesAutoresizingMaskIntoConstraints = true + row.frame = CGRect(x: 0, y: 24, width: 840, height: height) + let canvas = NSView(frame: CGRect(x: 0, y: 0, width: 840, height: height + 48)) + canvas.wantsLayer = true + canvas.addSubview(row) + let window = NSWindow( + contentRect: canvas.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.appearance = NSAppearance(named: dark ? .darkAqua : .aqua) + window.contentView = canvas + window.appearance?.performAsCurrentDrawingAppearance { + canvas.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + } + window.orderFront(nil) + defer { + window.orderOut(nil) + window.contentView = nil + } + row.layoutSubtreeIfNeeded() + try await Task.sleep(for: .milliseconds(400)) + let bitmap = try #require(canvas.bitmapImageRepForCachingDisplay(in: canvas.bounds)) + canvas.cacheDisplay(in: canvas.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + let url = directory.appending( + path: "mac-\(language)-work-\(phase.rawValue)-\(dark ? "dark" : "light").png") + try data.write(to: url) + print("Assistant snapshot: \(url.path)") + } + } + } + } +}