diff --git a/Termind/App/AppModel.swift b/Termind/App/AppModel.swift index 4930f814..176f3da0 100644 --- a/Termind/App/AppModel.swift +++ b/Termind/App/AppModel.swift @@ -256,7 +256,7 @@ final class AppModel { includesTerminalSuggestion: Bool = false ) { let draft = conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !draft.isEmpty else { return } + guard conversation.canSendDraft else { return } // The *returned* text, not `draft`: attachments are folded in there, and // running on the pre-fold string would show the model a turn that does // not match the one the conversation recorded. @@ -272,7 +272,8 @@ final class AppModel { await compactIfNeeded(conversation) startAgentRun( conversation, promptID: prompt.id, prompt: prompt.text, - authoredPrompt: prompt.authoredText, promptImages: prompt.images, context: context, + authoredPrompt: prompt.authoredText, promptImages: prompt.images, + promptContextAttachments: prompt.contextAttachments, context: context, includesTerminalSuggestion: includesTerminalSuggestion ) } @@ -284,6 +285,7 @@ final class AppModel { prompt: String? = nil, authoredPrompt: String? = nil, promptImages: [AgentImageAttachment]? = nil, + promptContextAttachments: [AgentContextAttachment]? = nil, context: SessionContext = .none, includesTerminalSuggestion: Bool = false ) { @@ -295,7 +297,11 @@ final class AppModel { let userImages = promptImages ?? lastUserTurn?.images ?? [] - guard !userPrompt.isEmpty else { return } + let userContextAttachments = promptContextAttachments + ?? lastUserTurn?.contextAttachments + ?? [] + guard !userPrompt.isEmpty || !userImages.isEmpty || !userContextAttachments.isEmpty + else { return } guard let chat = ai.resolve(conversation: conversation.model) else { conversation.messages.append( ChatMessage( @@ -426,6 +432,7 @@ final class AppModel { promptID: promptID ?? lastUserTurn?.id ?? UUID(), prompt: userPrompt, authoredPrompt: authoredPrompt ?? lastUserTurn?.authoredText, promptImages: userImages, + promptContextAttachments: userContextAttachments, permissionMode: mode, isPlanning: isPlanning, priorMessages: conversation.pendingPriorMessages, sessionContext: context @@ -598,11 +605,17 @@ final class AppModel { /// instead of staying labelled by its opening line forever. static func titleContext(for conversation: ConversationModel) -> ConversationTitleContext? { guard conversation.title == nil, - let user = conversation.transcript.first(where: { $0.role == .user })?.text, + let firstUser = conversation.transcript.first(where: { $0.role == .user }), let assistant = conversation.transcript.first(where: { $0.role == .assistant && !$0.text.isEmpty })?.text else { return nil } + let user = firstUser.text.isEmpty + ? firstUser.images.first?.label + ?? firstUser.contextAttachments.first?.label + ?? "" + : firstUser.text + guard !user.isEmpty else { return nil } return ConversationTitleContext( firstUserMessage: user, firstAssistantMessage: assistant ) @@ -811,6 +824,7 @@ final class AppModel { self.startAgentRun( conversation, promptID: prompt.id, prompt: prompt.text, authoredPrompt: prompt.authoredText, promptImages: prompt.images, + promptContextAttachments: prompt.contextAttachments, context: context, includesTerminalSuggestion: includesTerminalSuggestion ) diff --git a/Termind/App/TermindApp.swift b/Termind/App/TermindApp.swift index 7ce13e99..8c40607c 100644 --- a/Termind/App/TermindApp.swift +++ b/Termind/App/TermindApp.swift @@ -14,6 +14,7 @@ struct TermindApp: App { private let launchLanguage = AppLanguage.stored() init() { + ContextAttachmentOpener.cleanPreviousSession() do { let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil diff --git a/Termind/Application/Agent/AgentRuntime.swift b/Termind/Application/Agent/AgentRuntime.swift index afcd51cb..5fc05fb3 100644 --- a/Termind/Application/Agent/AgentRuntime.swift +++ b/Termind/Application/Agent/AgentRuntime.swift @@ -128,7 +128,9 @@ actor AgentRuntime { @discardableResult func steer(_ message: AgentTranscriptMessage) -> Bool { guard acceptsSteering else { return false } - guard !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + guard !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || !message.images.isEmpty || !message.contextAttachments.isEmpty + else { return false } steering.append(message) @@ -164,7 +166,8 @@ actor AgentRuntime { } let prompt = AgentTranscriptMessage( id: request.promptID, role: .user, text: request.prompt, - authoredText: request.authoredPrompt, images: request.promptImages + authoredText: request.authoredPrompt, images: request.promptImages, + contextAttachments: request.promptContextAttachments ) snapshot.messages.append(prompt) let pipeline = configuration.contextPipeline( diff --git a/Termind/Application/FeatureModels/AssistantLibraryModel.swift b/Termind/Application/FeatureModels/AssistantLibraryModel.swift index 5c7bab8f..12defc74 100644 --- a/Termind/Application/FeatureModels/AssistantLibraryModel.swift +++ b/Termind/Application/FeatureModels/AssistantLibraryModel.swift @@ -77,7 +77,8 @@ final class AssistantConversationStore { id: message.id, role: .user, text: message.authoredText ?? ContextChip.visiblePrompt(in: message.text), contextChips: ContextChip.visibleReferences( - in: message.text, images: message.images + in: message.text, images: message.images, + contextAttachments: message.contextAttachments ), images: message.images, createdAt: message.createdAt diff --git a/Termind/Domain/Agent/AgentTypes.swift b/Termind/Domain/Agent/AgentTypes.swift index 123d684b..8b7fb096 100644 --- a/Termind/Domain/Agent/AgentTypes.swift +++ b/Termind/Domain/Agent/AgentTypes.swift @@ -550,6 +550,27 @@ nonisolated struct AgentImageAttachment: Identifiable, Hashable, Sendable, Codab var pixelHeight: Int } +/// A user-visible, non-image context attachment retained for opening after the +/// turn has been sent. `data` is presentation backing only: provider adapters +/// continue to receive the extracted text embedded in the user message, never +/// these original file bytes. +nonisolated struct AgentContextAttachment: Identifiable, Hashable, Sendable, Codable { + nonisolated enum Kind: String, Hashable, Sendable, Codable { + case file, output, selection, pastedText, host, directory + } + + var id: UUID = UUID() + var kind: Kind + var label: String + var contentTypeIdentifier: String + var suggestedFilename: String + var data: Data + /// False when `data` is an exported text representation rather than an + /// original file snapshot. Those open as `.txt`, never as a fake PDF or + /// Office document. + var isOriginalFile: Bool = true +} + /// Provider-authored, user-visible reasoning text. /// /// This is deliberately separate from `providerItems`: `text` is safe to draw @@ -570,6 +591,9 @@ nonisolated struct AgentTranscriptMessage: Identifiable, Hashable, Sendable, Cod /// Nil on assistant/tool turns and on stores created before message editing. var authoredText: String? var images: [AgentImageAttachment] = [] + /// Local presentation backing for sent context chips. This is deliberately + /// ignored by every provider wire encoder. + var contextAttachments: [AgentContextAttachment] = [] var reasoning: [AgentReasoningBlock] = [] var toolCalls: [AgentToolCall] = [] var toolCallID: String? @@ -705,6 +729,7 @@ nonisolated struct AgentRunRequest: Sendable { var prompt: String var authoredPrompt: String? var promptImages: [AgentImageAttachment] + var promptContextAttachments: [AgentContextAttachment] var permissionMode: ChatPermissionMode /// Whether this run may only look. /// @@ -725,6 +750,7 @@ nonisolated struct AgentRunRequest: Sendable { prompt: String, authoredPrompt: String? = nil, promptImages: [AgentImageAttachment] = [], + promptContextAttachments: [AgentContextAttachment] = [], permissionMode: ChatPermissionMode, isPlanning: Bool = false, systemPrompt: String = AgentSystemPrompt.default, @@ -736,6 +762,7 @@ nonisolated struct AgentRunRequest: Sendable { self.prompt = prompt self.authoredPrompt = authoredPrompt self.promptImages = promptImages + self.promptContextAttachments = promptContextAttachments self.permissionMode = permissionMode self.isPlanning = isPlanning self.systemPrompt = systemPrompt diff --git a/Termind/Features/AI/ContextAttachmentImporter.swift b/Termind/Features/AI/ContextAttachmentImporter.swift index 5cdb6fdc..729f780a 100644 --- a/Termind/Features/AI/ContextAttachmentImporter.swift +++ b/Termind/Features/AI/ContextAttachmentImporter.swift @@ -42,23 +42,23 @@ nonisolated enum ContextAttachmentImporter { if ["doc", "xls", "ppt"].contains(ext) { throw ImportError.legacyOffice } if ext == "pdf" { - return ContextChip(kind: .file, label: label, content: try pdfText(data)) + return fileChip(label: label, data: data, content: try pdfText(data), url: url) } if ext == "docx" { - return ContextChip(kind: .file, label: label, content: try docxText(data)) + return fileChip(label: label, data: data, content: try docxText(data), url: url) } if ext == "pptx" { - return ContextChip(kind: .file, label: label, content: try pptxText(data)) + return fileChip(label: label, data: data, content: try pptxText(data), url: url) } if ext == "xlsx" { - return ContextChip(kind: .file, label: label, content: try xlsxText(data)) + return fileChip(label: label, data: data, content: try xlsxText(data), url: url) } if isImage(url: url) { let image = try normalizedImage(data: data, label: label) return ContextChip(kind: .image, label: label, image: image) } guard isText(url: url) else { throw ImportError.unsupportedFormat } - return ContextChip(kind: .file, label: label, content: try decodedText(data)) + return fileChip(label: label, data: data, content: try decodedText(data), url: url) } return try await withTaskCancellationHandler { try await task.value @@ -89,6 +89,21 @@ nonisolated enum ContextAttachmentImporter { text.count >= 2_000 || text.split(separator: "\n", omittingEmptySubsequences: false).count >= 20 } + private static func fileChip( + label: String, data: Data, content: String, url: URL + ) -> ContextChip { + let attachment = AgentContextAttachment( + kind: .file, label: label, + contentTypeIdentifier: UTType(filenameExtension: url.pathExtension)?.identifier + ?? UTType.data.identifier, + suggestedFilename: label, data: data + ) + return ContextChip( + id: attachment.id, kind: .file, label: label, content: content, + sourceAttachment: attachment + ) + } + private static func isImage(url: URL) -> Bool { guard let type = UTType(filenameExtension: url.pathExtension) else { return false } return type.conforms(to: .image) diff --git a/Termind/Features/AI/ContextAttachmentOpener.swift b/Termind/Features/AI/ContextAttachmentOpener.swift new file mode 100644 index 00000000..90559dc1 --- /dev/null +++ b/Termind/Features/AI/ContextAttachmentOpener.swift @@ -0,0 +1,96 @@ +import AppKit +import Foundation +import UniformTypeIdentifiers + +nonisolated enum ContextAttachmentOpener { + private static let directoryName = "Termind-Context-Previews" + + static var previewDirectory: URL { + FileManager.default.temporaryDirectory + .appendingPathComponent(directoryName, isDirectory: true) + } + + /// Preview files are snapshots and can contain sensitive context. They are + /// kept only long enough for the receiving app to read them, then removed + /// the next time Termind launches. + static func cleanPreviousSession() { + try? FileManager.default.removeItem(at: previewDirectory) + } + + static func materialize(_ attachment: AgentContextAttachment) throws -> URL { + let manager = FileManager.default + try manager.createDirectory( + at: previewDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let name = safeFilename(for: attachment) + let folder = previewDirectory.appendingPathComponent( + attachment.id.uuidString, isDirectory: true + ) + try manager.createDirectory( + at: folder, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let destination = folder.appendingPathComponent(name, isDirectory: false) + if !manager.fileExists(atPath: destination.path) { + try attachment.data.write(to: destination, options: .atomic) + try manager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) + } + return destination + } + + @MainActor + static func open(_ attachment: AgentContextAttachment) async throws { + let url = try await Task.detached(priority: .userInitiated) { + try materialize(attachment) + }.value + guard NSWorkspace.shared.open(url) else { + throw OpenError.noDefaultApplication + } + } + + @MainActor + static func open(_ image: AgentImageAttachment) async throws { + let type = UTType(mimeType: image.mimeType) ?? .image + let attachment = AgentContextAttachment( + id: image.id, kind: .file, label: image.label, + contentTypeIdentifier: type.identifier, + suggestedFilename: image.label, data: image.data + ) + try await open(attachment) + } + + static func safeFilename(for attachment: AgentContextAttachment) -> String { + var name = (attachment.suggestedFilename as NSString).lastPathComponent + .trimmingCharacters(in: .whitespacesAndNewlines) + name = name.unicodeScalars.map { + CharacterSet.controlCharacters.contains($0) ? "-" : String($0) + }.joined() + name = name.replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ":", with: "-") + while name.hasPrefix(".") { name.removeFirst() } + if name.isEmpty { name = "Attachment" } + if (name as NSString).pathExtension.isEmpty, + let ext = UTType(attachment.contentTypeIdentifier)?.preferredFilenameExtension + { + name += ".\(ext)" + } + let ext = (name as NSString).pathExtension + guard name.count > 180, !ext.isEmpty else { return String(name.prefix(180)) } + let suffix = ".\(ext)" + let stemLimit = max(1, 180 - suffix.count) + let stem = (name as NSString).deletingPathExtension + return String(stem.prefix(stemLimit)) + suffix + } + + enum OpenError: LocalizedError { + case noDefaultApplication + + var errorDescription: String? { + switch self { + case .noDefaultApplication: + String(localized: "No application is available to open this attachment.") + } + } + } +} diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index 60172891..e15f90bb 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -62,6 +62,34 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable { var label: String var content: String = "" var image: AgentImageAttachment? = nil + /// Original non-image bytes used only to reopen a sent attachment. The + /// model still receives `content`, which is the validated extraction. + var sourceAttachment: AgentContextAttachment? = nil + + var contextAttachment: AgentContextAttachment? { + guard image == nil, let attachmentKind = kind.agentAttachmentKind else { return nil } + if let sourceAttachment { + return AgentContextAttachment( + id: id, kind: attachmentKind, label: label, + contentTypeIdentifier: sourceAttachment.contentTypeIdentifier, + suggestedFilename: sourceAttachment.suggestedFilename, + data: sourceAttachment.data, + isOriginalFile: sourceAttachment.isOriginalFile + ) + } + let filename: String + if kind == .file { + let stem = (label as NSString).deletingPathExtension + filename = "\(stem.isEmpty ? label : stem) extracted.txt" + } else { + filename = (label as NSString).pathExtension.isEmpty ? "\(label).txt" : label + } + return AgentContextAttachment( + id: id, kind: attachmentKind, label: label, + contentTypeIdentifier: "public.plain-text", suggestedFilename: filename, + data: Data(content.utf8), isOriginalFile: false + ) + } /// Derived, not declared. A hand-passed count is a number that was true /// once, and the estimate exists to warn about payload size. @@ -93,13 +121,15 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable { /// Rebuilds lightweight bubble chips from the model-facing markup without /// retaining or rendering the payload between the tags. static func visibleReferences( - in composedText: String, images: [AgentImageAttachment] = [] + in composedText: String, images: [AgentImageAttachment] = [], + contextAttachments: [AgentContextAttachment] = [] ) -> [ContextChipReference] { let boundary = Kind.allCases.compactMap { kind in composedText.range(of: "\n\n<\(kind.blockName) label=\"")?.lowerBound }.min() var matches: [(String.Index, ContextChipReference)] = [] + var unusedAttachments = contextAttachments if let boundary { let suffix = composedText[boundary...] for kind in Kind.allCases where kind != .image { @@ -110,13 +140,31 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable { guard let labelEnd = suffix.range( of: "\">", range: labelStart.. AgentContextAttachment? { + guard let kind = agentAttachmentKind else { return nil } + var text = content + if text.hasPrefix("\n") { text.removeFirst() } + if text.hasSuffix("\n") { text.removeLast() } + let filename: String + if self == .file { + let stem = (label as NSString).deletingPathExtension + filename = "\(stem.isEmpty ? label : stem) extracted.txt" + } else { + filename = (label as NSString).pathExtension.isEmpty ? "\(label).txt" : label + } + return AgentContextAttachment( + kind: kind, label: label, contentTypeIdentifier: "public.plain-text", + suggestedFilename: filename, data: Data(text.utf8), isOriginalFile: false + ) + } } nonisolated struct PendingContextAttachment: Identifiable, Hashable, Sendable { @@ -718,10 +801,21 @@ final class ConversationModel: Identifiable { guard let first = messages.first(where: { $0.role == .user }) else { return String(localized: "New Chat") } + if first.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let attachment = first.contextChips.first?.label ?? first.images.first?.label + { + return attachment + } let line = first.text.split(separator: "\n").first.map(String.init) ?? first.text return line.count > 40 ? String(line.prefix(40)) + "…" : line } + /// User-authored content only. Surface-owned session context is supplied + /// separately and must never make an otherwise empty composer sendable. + var canSendDraft: Bool { + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty + } + var isEmpty: Bool { timeline.isEmpty } var isArchived: Bool { archivedAt != nil } @@ -752,7 +846,8 @@ final class ConversationModel: Identifiable { ? String(original.text.dropFirst(visible.count)) : "" return AgentTranscriptMessage( role: .user, text: authoredText + suffix, authoredText: authoredText, - images: original.images, createdAt: .now + images: original.images, contextAttachments: original.contextAttachments, + createdAt: .now ) } @@ -772,7 +867,8 @@ final class ConversationModel: Identifiable { id: replacement.id, role: .user, text: replacement.authoredText ?? ContextChip.visiblePrompt(in: replacement.text), contextChips: ContextChip.visibleReferences( - in: replacement.text, images: replacement.images + in: replacement.text, images: replacement.images, + contextAttachments: replacement.contextAttachments ), images: replacement.images, createdAt: replacement.createdAt )]) } @@ -794,7 +890,8 @@ final class ConversationModel: Identifiable { } return ComposedUserTurn( id: replacement.id, text: replacement.text, - authoredText: replacement.authoredText ?? "", images: replacement.images + authoredText: replacement.authoredText ?? "", images: replacement.images, + contextAttachments: replacement.contextAttachments ) } @@ -839,7 +936,8 @@ final class ConversationModel: Identifiable { id: message.id, role: .user, text: message.authoredText ?? ContextChip.visiblePrompt(in: message.text), contextChips: ContextChip.visibleReferences( - in: message.text, images: message.images + in: message.text, images: message.images, + contextAttachments: message.contextAttachments ), images: message.images, createdAt: message.createdAt ) case .assistant: @@ -1274,7 +1372,7 @@ final class ConversationModel: Identifiable { /// path; the sidebar promotion flow uses this before starting that run. func sendDraft() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + guard canSendDraft else { return } appendUserTurn(text) draft = "" } @@ -1302,6 +1400,7 @@ final class ConversationModel: Identifiable { var text: String var authoredText: String var images: [AgentImageAttachment] + var contextAttachments: [AgentContextAttachment] } @discardableResult @@ -1309,7 +1408,10 @@ final class ConversationModel: Identifiable { let composed = composed(text) let messageID = UUID() let contextChips = attachments.map { - ContextChipReference(id: $0.id, kind: $0.kind, label: $0.label) + ContextChipReference( + id: $0.id, kind: $0.kind, label: $0.label, + attachment: $0.contextAttachment + ) } attachments.removeAll() messages.append( @@ -1321,12 +1423,14 @@ final class ConversationModel: Identifiable { transcript.append( AgentTranscriptMessage( id: messageID, role: .user, text: composed.text, - authoredText: text, images: composed.images + authoredText: text, images: composed.images, + contextAttachments: composed.contextAttachments ) ) updatedAt = .now return ComposedUserTurn( - id: messageID, text: composed.text, authoredText: text, images: composed.images + id: messageID, text: composed.text, authoredText: text, images: composed.images, + contextAttachments: composed.contextAttachments ) } @@ -1336,7 +1440,10 @@ final class ConversationModel: Identifiable { /// belong in a Markdown bubble. private func composed(_ text: String) -> ComposedUserTurn { guard !attachments.isEmpty else { - return ComposedUserTurn(id: UUID(), text: text, authoredText: text, images: []) + return ComposedUserTurn( + id: UUID(), text: text, authoredText: text, images: [], + contextAttachments: [] + ) } let blocks = attachments.compactMap { chip -> String? in guard chip.image == nil else { return nil } @@ -1352,7 +1459,8 @@ final class ConversationModel: Identifiable { return ComposedUserTurn( id: UUID(), text: ([text] + blocks).joined(separator: "\n\n"), authoredText: text, - images: attachments.compactMap(\.image) + images: attachments.compactMap(\.image), + contextAttachments: attachments.compactMap(\.contextAttachment) ) } @@ -1418,13 +1526,13 @@ final class ConversationModel: Identifiable { /// really saw it. func submitSteeringDraft() { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, let runtime else { return } + guard canSendDraft, let runtime else { return } let composed = composed(text) let pending = PendingSteeringMessage( draft: text, attachments: attachments, message: AgentTranscriptMessage( role: .user, text: composed.text, authoredText: text, - images: composed.images + images: composed.images, contextAttachments: composed.contextAttachments ) ) pendingSteering.append(pending) @@ -1914,8 +2022,14 @@ final class ConversationModel: Identifiable { let pending = pendingSteering.first(where: { $0.id == message.id }) let visibleText = pending?.draft ?? ContextChip.visiblePrompt(in: message.text) let contextChips = pending?.attachments.map { - ContextChipReference(id: $0.id, kind: $0.kind, label: $0.label) - } ?? ContextChip.visibleReferences(in: message.text, images: message.images) + ContextChipReference( + id: $0.id, kind: $0.kind, label: $0.label, + attachment: $0.contextAttachment + ) + } ?? ContextChip.visibleReferences( + in: message.text, images: message.images, + contextAttachments: message.contextAttachments + ) pendingSteering.removeAll { $0.id == message.id } messages.append( ChatMessage( diff --git a/Termind/Features/AI/ConversationViews.swift b/Termind/Features/AI/ConversationViews.swift index 41b19a35..cd120ea8 100644 --- a/Termind/Features/AI/ConversationViews.swift +++ b/Termind/Features/AI/ConversationViews.swift @@ -621,17 +621,13 @@ struct ComposerView: View { /// when the field gains content. private static let editorTextInset: CGFloat = 5 - private var trimmedDraft: String { - conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines) - } - /// Stop and Send share one slot rather than standing side by side. /// /// Drawn together they read as a pair of equals, and the primary action /// moved under the pointer the moment the draft gained a character. With a /// draft to send, Send is what the button is for; with nothing to send, /// stopping is the only thing left to do. - private var showsStopButton: Bool { conversation.isStreaming && trimmedDraft.isEmpty } + private var showsStopButton: Bool { conversation.isStreaming && !conversation.canSendDraft } private var standardControls: some View { HStack(spacing: Metrics.Space.s) { @@ -646,7 +642,7 @@ struct ComposerView: View { stopButton } else { sendButton( - enabled: !trimmedDraft.isEmpty && conversation.pendingAttachments.isEmpty, + enabled: conversation.canSendDraft && conversation.pendingAttachments.isEmpty, help: conversation.isStreaming ? "Queue for the running agent — ⏎" : "Send — ⌘⏎", action: sendDraft diff --git a/Termind/Features/AI/MessageViews.swift b/Termind/Features/AI/MessageViews.swift index e2d9b190..01c2d6ff 100644 --- a/Termind/Features/AI/MessageViews.swift +++ b/Termind/Features/AI/MessageViews.swift @@ -18,6 +18,7 @@ struct UserMessageView: View { @State private var editText = "" @State private var bubbleWidth: CGFloat = 0 @State private var hasMarkedText = false + @State private var attachmentOpenError: String? @FocusState private var editFocused: Bool var body: some View { @@ -44,6 +45,17 @@ struct UserMessageView: View { .onHover { isHovering = $0 } .accessibilityElement(children: .contain) .accessibilityLabel(Text("You said")) + .alert( + "Couldn’t Open Attachment", + isPresented: Binding( + get: { attachmentOpenError != nil }, + set: { if !$0 { attachmentOpenError = nil } } + ) + ) { + Button("OK", role: .cancel) { attachmentOpenError = nil } + } message: { + Text(attachmentOpenError ?? "") + } } private func beginEdit() { @@ -56,13 +68,19 @@ struct UserMessageView: View { VStack(alignment: .leading, spacing: Metrics.Space.s) { if !message.contextChips.isEmpty { FlowLayout(spacing: Metrics.Space.xs, fillsWidth: false) { - ForEach(message.contextChips) { BubbleContextChipView(chip: $0) } + ForEach(message.contextChips) { chip in + BubbleContextChipView(chip: chip) { open(chip) } + } } } if !message.images.isEmpty { FlowLayout(spacing: Metrics.Space.s, fillsWidth: false) { - ForEach(message.images) { - BubbleImageView(attachment: $0, isCompact: isCompact) + ForEach(message.images) { attachment in + Button { open(attachment) } label: { + BubbleImageView(attachment: attachment, isCompact: isCompact) + } + .buttonStyle(.plain) + .help(Text("Open \(attachment.label)")) } } } @@ -87,6 +105,34 @@ struct UserMessageView: View { } } + private func open(_ chip: ContextChipReference) { + if chip.kind == .image, + let image = message.images.first(where: { $0.id == chip.id }) + ?? message.images.first(where: { $0.label == chip.label }) + { + open(image) + return + } + guard let attachment = chip.attachment else { return } + Task { + do { + try await ContextAttachmentOpener.open(attachment) + } catch { + attachmentOpenError = error.localizedDescription + } + } + } + + private func open(_ image: AgentImageAttachment) { + Task { + do { + try await ContextAttachmentOpener.open(image) + } catch { + attachmentOpenError = error.localizedDescription + } + } + } + private var editor: some View { VStack(alignment: .trailing, spacing: Metrics.Space.s) { TextEditor(text: $editText) @@ -148,8 +194,20 @@ struct UserMessageView: View { private struct BubbleContextChipView: View { let chip: ContextChipReference + var open: (() -> Void)? var body: some View { + if let open { + Button(action: open) { content } + .buttonStyle(.plain) + .help(Text("Open \(chip.label)")) + .accessibilityLabel(Text("Open \(chip.label)")) + } else { + content + } + } + + private var content: some View { HStack(spacing: Metrics.Space.xs) { Image(systemName: chip.kind.symbol) .font(.caption2) diff --git a/Termind/Features/Tabs/TabContentView.swift b/Termind/Features/Tabs/TabContentView.swift index f9c83980..2e46d2cb 100644 --- a/Termind/Features/Tabs/TabContentView.swift +++ b/Termind/Features/Tabs/TabContentView.swift @@ -709,9 +709,13 @@ struct WelcomeView: View { private var header: some View { VStack(spacing: Metrics.Space.s) { - Image(systemName: "sparkles") - .font(.system(size: 40)) + Image("TermindGlyph") + .resizable() + .renderingMode(.template) + .scaledToFit() + .frame(width: 88, height: 88) .foregroundStyle(.tertiary) + .accessibilityHidden(true) Text("Termind") .font(.largeTitle.weight(.semibold)) Text("SSH and SFTP, with an assistant at hand.") diff --git a/Termind/Infrastructure/Database/AgentPersistence.swift b/Termind/Infrastructure/Database/AgentPersistence.swift index 6f28808a..e75b9995 100644 --- a/Termind/Infrastructure/Database/AgentPersistence.swift +++ b/Termind/Infrastructure/Database/AgentPersistence.swift @@ -98,6 +98,25 @@ final class PersistentAgentMessage { } } +/// V3 storage for original context snapshots. A separate model keeps the V2 +/// message schema frozen and makes the migration an additive lightweight one. +@Model +final class PersistentAgentContextAttachments { + @Attribute(.unique) var messageID: UUID = UUID() + @Attribute(.externalStorage) var attachmentsData = Data() + + init(messageID: UUID, attachments: [AgentContextAttachment]) { + self.messageID = messageID + attachmentsData = (try? JSONEncoder().encode(attachments)) ?? Data() + } + + var attachments: [AgentContextAttachment] { + (try? JSONDecoder().decode( + [AgentContextAttachment].self, from: attachmentsData + )) ?? [] + } +} + @Model final class PersistentAgentToolInvocation { @Attribute(.unique) var id: UUID = UUID() @@ -232,6 +251,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { context.insert(PersistentAgentMessage( message, runID: runID, sequence: sequence )) + insertContextAttachments(for: message) inserted = true } guard inserted || context.hasChanges else { return } @@ -278,6 +298,12 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { )) let runs = try context.fetch(FetchDescriptor()) let messages = try context.fetch(FetchDescriptor()) + let attachmentRows = try context.fetch( + FetchDescriptor() + ) + let attachmentsByMessage = Dictionary( + uniqueKeysWithValues: attachmentRows.map { ($0.messageID, $0.attachments) } + ) let invocations = try context.fetch(FetchDescriptor()) let results = try context.fetch(FetchDescriptor()) let reviews = try context.fetch(FetchDescriptor()) @@ -319,12 +345,14 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { let images = (try? JSONDecoder().decode( [AgentImageAttachment].self, from: row.imagesData )) ?? [] + let contextAttachments = attachmentsByMessage[row.id] ?? [] let reasoning = (try? JSONDecoder().decode( [AgentReasoningBlock].self, from: row.reasoningData )) ?? [] return AgentTranscriptMessage( id: row.id, role: role, text: row.text, authoredText: row.authoredText, images: images, + contextAttachments: contextAttachments, reasoning: reasoning, toolCalls: calls, toolCallID: row.toolCallID, toolName: row.toolName, @@ -445,6 +473,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { context.insert(PersistentAgentMessage( replacement, runID: replacementRun.id, sequence: 0 )) + insertContextAttachments(for: replacement) let descriptor = FetchDescriptor( predicate: #Predicate { $0.id == id } @@ -508,6 +537,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { } ?? fallbackRunID, sequence: sequence )) + insertContextAttachments(for: message) } for card in value.toolCards { var invocation = card.invocation @@ -553,11 +583,22 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { where runIDs.contains(row.runID) { context.delete(row) } for row in try context.fetch(FetchDescriptor()) where runIDs.contains(row.runID) { context.delete(row) } - for row in try context.fetch(FetchDescriptor()) - where runIDs.contains(row.runID) { context.delete(row) } + let messages = try context.fetch(FetchDescriptor()) + .filter { runIDs.contains($0.runID) } + let messageIDs = Set(messages.map(\.id)) + for row in try context.fetch(FetchDescriptor()) + where messageIDs.contains(row.messageID) { context.delete(row) } + for row in messages { context.delete(row) } for row in runs { context.delete(row) } } + private func insertContextAttachments(for message: AgentTranscriptMessage) { + guard !message.contextAttachments.isEmpty else { return } + context.insert(PersistentAgentContextAttachments( + messageID: message.id, attachments: message.contextAttachments + )) + } + /// Puts back tool results that were recorded but never made it into the /// transcript. /// diff --git a/Termind/Infrastructure/Database/HostPersistence.swift b/Termind/Infrastructure/Database/HostPersistence.swift index 49bad068..fabc8a05 100644 --- a/Termind/Infrastructure/Database/HostPersistence.swift +++ b/Termind/Infrastructure/Database/HostPersistence.swift @@ -347,15 +347,26 @@ enum TermindSchemaV2: VersionedSchema { } } +enum TermindSchemaV3: VersionedSchema { + static let versionIdentifier = Schema.Version(3, 0, 0) + + static var models: [any PersistentModel.Type] { + TermindSchemaV2.models + [PersistentAgentContextAttachments.self] + } +} + enum TermindMigrationPlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [TermindSchemaV1.self, TermindSchemaV2.self] + [TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self] } static var stages: [MigrationStage] { [ MigrationStage.lightweight( fromVersion: TermindSchemaV1.self, toVersion: TermindSchemaV2.self + ), + MigrationStage.lightweight( + fromVersion: TermindSchemaV2.self, toVersion: TermindSchemaV3.self ) ] } @@ -363,7 +374,7 @@ enum TermindMigrationPlan: SchemaMigrationPlan { @MainActor final class HostPersistence { - static let schema = Schema(versionedSchema: TermindSchemaV2.self) + static let schema = Schema(versionedSchema: TermindSchemaV3.self) let context: ModelContext diff --git a/Termind/Resources/Localizable.xcstrings b/Termind/Resources/Localizable.xcstrings index 460c81da..84385043 100644 --- a/Termind/Resources/Localizable.xcstrings +++ b/Termind/Resources/Localizable.xcstrings @@ -728,6 +728,56 @@ } } }, + "%lldh" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld小时" + } + } + } + }, + "%lldh %lldm" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld小时%2$lld分钟" + } + } + } + }, + "%lldm" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld分钟" + } + } + } + }, + "%lldm %llds" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld分钟%2$lld秒" + } + } + } + }, + "%llds" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld秒" + } + } + } + }, "•" : { "localizations" : { "zh-Hans" : { @@ -1001,6 +1051,16 @@ } } }, + "a command" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "命令" + } + } + } + }, "A message can include at most 20 attachments." : { "localizations" : { "zh-Hans" : { @@ -1011,6 +1071,36 @@ } } }, + "a plan" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计划" + } + } + } + }, + "a question" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "问题" + } + } + } + }, + "a remote file" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程文件" + } + } + } + }, "A replacement password cannot be empty." : { "localizations" : { "zh-Hans" : { @@ -1021,6 +1111,36 @@ } } }, + "a scratch entry" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂存条目" + } + } + } + }, + "a scratch file" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂存文件" + } + } + } + }, + "a secret" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥信息" + } + } + } + }, "A truncated model response contained tool calls; none were executed." : { "localizations" : { "zh-Hans" : { @@ -1031,6 +1151,16 @@ } } }, + "a URL" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "URL" + } + } + } + }, "A username and password are required. The password stays in the Keychain." : { "localizations" : { "zh-Hans" : { @@ -1381,6 +1511,16 @@ } } }, + "Always Ask requires approval for every call. Follow Permissions uses the conversation's permission mode and safety checks." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "“始终询问”要求每次调用都由你批准;“跟随权限设置”则使用当前会话的权限模式和安全检查。" + } + } + } + }, "Always included" : { "localizations" : { "zh-Hans" : { @@ -1591,6 +1731,16 @@ } } }, + "ask" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "询问" + } + } + } + }, "Ask a question or describe what you want to do." : { "localizations" : { "zh-Hans" : { @@ -1632,6 +1782,7 @@ } }, "Ask First" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -2071,6 +2222,16 @@ } } }, + "change permissions for" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更改权限" + } + } + } + }, "Changes" : { "localizations" : { "zh-Hans" : { @@ -2617,6 +2778,16 @@ } } }, + "compare" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "比较" + } + } + } + }, "Completed" : { "localizations" : { "zh-Hans" : { @@ -2797,6 +2968,16 @@ } } }, + "Connecting to %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在连接%@" + } + } + } + }, "Connection" : { "localizations" : { "zh-Hans" : { @@ -3157,6 +3338,16 @@ } } }, + "Couldn’t Open Attachment" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法打开附件" + } + } + } + }, "Couldn’t compact context" : { "localizations" : { "zh-Hans" : { @@ -3177,6 +3368,16 @@ } } }, + "create" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "创建" + } + } + } + }, "Create" : { "localizations" : { "zh-Hans" : { @@ -3217,6 +3418,16 @@ } } }, + "Critical" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "严重" + } + } + } + }, "Current" : { "localizations" : { "zh-Hans" : { @@ -3317,6 +3528,16 @@ } } }, + "delete" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + } + } + }, "Delete" : { "localizations" : { "zh-Hans" : { @@ -3677,6 +3898,26 @@ } } }, + "Did not run “%@”" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未运行“%@”" + } + } + } + }, + "Did not run “%@” on %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未在%2$@上运行“%1$@”" + } + } + } + }, "Diff" : { "localizations" : { "zh-Hans" : { @@ -3697,6 +3938,16 @@ } } }, + "Directory" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目录" + } + } + } + }, "Directory created" : { "localizations" : { "zh-Hans" : { @@ -3877,6 +4128,16 @@ } } }, + "edit" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑" + } + } + } + }, "Edit" : { "localizations" : { "zh-Hans" : { @@ -4403,6 +4664,36 @@ } } }, + "Failed to run “%@”" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未能运行“%@”" + } + } + } + }, + "Failed to run “%@” on %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未能在%2$@上运行“%1$@”" + } + } + } + }, + "fetch" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "获取" + } + } + } + }, "Fetch" : { "localizations" : { "zh-Hans" : { @@ -4433,6 +4724,16 @@ } } }, + "File" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文件" + } + } + } + }, "File Access" : { "localizations" : { "zh-Hans" : { @@ -5114,6 +5415,16 @@ } } }, + "hosts" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主机" + } + } + } + }, "Hosts" : { "localizations" : { "zh-Hans" : { @@ -5420,6 +5731,16 @@ } } }, + "inspect" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检查" + } + } + } + }, "Interactive partition editor." : { "localizations" : { "zh-Hans" : { @@ -5806,6 +6127,16 @@ } } }, + "list" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "列出" + } + } + } + }, "List saved hosts" : { "localizations" : { "zh-Hans" : { @@ -6142,6 +6473,16 @@ } } }, + "move" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移动" + } + } + } + }, "Name" : { "localizations" : { "zh-Hans" : { @@ -6612,6 +6953,16 @@ } } }, + "No application is available to open this attachment." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "没有可用于打开此附件的应用。" + } + } + } + }, "No key saved yet. Termind saves it on first connection and warns if it changes." : { "localizations" : { "zh-Hans" : { @@ -7043,6 +7394,7 @@ } }, "Offer" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -7122,6 +7474,16 @@ } } }, + "Open %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开%@" + } + } + } + }, "Open %lld hosts as panes in one workspace." : { "localizations" : { "zh-Hans" : { @@ -7712,6 +8074,26 @@ } } }, + "Preparing to run “%@”" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在准备运行“%@”" + } + } + } + }, + "Preparing to run “%@” on %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在准备在%2$@上运行“%1$@”" + } + } + } + }, "Preparing upload…" : { "localizations" : { "zh-Hans" : { @@ -7722,6 +8104,16 @@ } } }, + "present" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "提交" + } + } + } + }, "Press Fetch" : { "localizations" : { "zh-Hans" : { @@ -7942,6 +8334,16 @@ } } }, + "pull" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拉取" + } + } + } + }, "Purple" : { "localizations" : { "zh-Hans" : { @@ -7962,6 +8364,16 @@ } } }, + "push" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推送" + } + } + } + }, "Question" : { "localizations" : { "zh-Hans" : { @@ -8068,6 +8480,26 @@ } } }, + "Ran “%@”" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已运行“%@”" + } + } + } + }, + "Ran “%@” on %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已在%2$@上运行“%1$@”" + } + } + } + }, "Ran %@" : { "localizations" : { "zh-Hans" : { @@ -8088,6 +8520,16 @@ } } }, + "read" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "读取" + } + } + } + }, "Read" : { "localizations" : { "zh-Hans" : { @@ -8500,6 +8942,26 @@ } } }, + "Repeated tool action failed: %lld %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld次%2$@失败" + } + } + } + }, + "Repeated tool action interrupted: %lld %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld次%2$@已中断" + } + } + } + }, "Replace" : { "localizations" : { "zh-Hans" : { @@ -8530,6 +8992,16 @@ } } }, + "request" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请求" + } + } + } + }, "Request" : { "localizations" : { "zh-Hans" : { @@ -8850,6 +9322,16 @@ } } }, + "run" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "运行" + } + } + } + }, "Run" : { "localizations" : { "zh-Hans" : { @@ -8920,6 +9402,26 @@ } } }, + "Running “%@”" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在运行“%@”" + } + } + } + }, + "Running “%@” on %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在%2$@上运行“%1$@”" + } + } + } + }, "Running %@" : { "localizations" : { "zh-Hans" : { @@ -9020,6 +9522,16 @@ } } }, + "scratch files" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂存文件" + } + } + } + }, "Scratch path" : { "localizations" : { "zh-Hans" : { @@ -9050,6 +9562,16 @@ } } }, + "search" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索" + } + } + } + }, "Search History" : { "localizations" : { "zh-Hans" : { @@ -10168,6 +10690,26 @@ } } }, + "Stopped waiting for “%@”" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已停止等待“%@”" + } + } + } + }, + "Stopped waiting for “%@” on %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已停止等待%2$@上的“%1$@”" + } + } + } + }, "Stops or disables a running service." : { "localizations" : { "zh-Hans" : { @@ -10248,6 +10790,16 @@ } } }, + "suggest" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "生成建议" + } + } + } + }, "Suggested Command" : { "localizations" : { "zh-Hans" : { @@ -10278,6 +10830,16 @@ } } }, + "Symlink" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "符号链接" + } + } + } + }, "Sync" : { "localizations" : { "zh-Hans" : { @@ -11298,6 +11860,16 @@ } } }, + "the remote command" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程命令" + } + } + } + }, "The remote shell did not return its current input." : { "localizations" : { "zh-Hans" : { @@ -11358,6 +11930,16 @@ } } }, + "the scratch workspace" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暂存工作区" + } + } + } + }, "The search query is empty." : { "localizations" : { "zh-Hans" : { @@ -11528,6 +12110,16 @@ } } }, + "the SSH command" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH命令" + } + } + } + }, "The SSH connection closed." : { "localizations" : { "zh-Hans" : { @@ -11608,6 +12200,16 @@ } } }, + "the web" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "网络" + } + } + } + }, "The Web Search endpoint redirected somewhere Termind will not follow." : { "localizations" : { "zh-Hans" : { @@ -11936,6 +12538,86 @@ } } }, + "Tool action completed: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已%1$@%2$@" + } + } + } + }, + "Tool action denied: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未%1$@%2$@" + } + } + } + }, + "Tool action failed: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@失败" + } + } + } + }, + "Tool action interrupted: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@%2$@已中断" + } + } + } + }, + "Tool action preparing: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在准备%1$@%2$@" + } + } + } + }, + "Tool action running: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在%1$@%2$@" + } + } + } + }, + "Tool action target: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(%2$@)" + } + } + } + }, + "Tool action waiting: %@ %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待%1$@%2$@" + } + } + } + }, "Tool changes are enforced immediately. Existing clients see the new list when they reconnect or list tools again." : { "localizations" : { "zh-Hans" : { @@ -12376,6 +13058,16 @@ } } }, + "update" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新" + } + } + } + }, "Updates or may update local Git references." : { "localizations" : { "zh-Hans" : { @@ -12436,6 +13128,16 @@ } } }, + "use" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用" + } + } + } + }, "Use Security Review automatically" : { "localizations" : { "zh-Hans" : { @@ -12616,6 +13318,16 @@ } } }, + "wait for" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待" + } + } + } + }, "Wait for the shell prompt to report its current directory." : { "localizations" : { "zh-Hans" : { @@ -12696,6 +13408,36 @@ } } }, + "Waiting to authenticate with %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待向%@进行身份验证" + } + } + } + }, + "Waiting to run “%@”" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待运行“%@”" + } + } + } + }, + "Waiting to run “%@” on %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待在%2$@上运行“%1$@”" + } + } + } + }, "Waiting to run %@" : { "localizations" : { "zh-Hans" : { @@ -12806,16 +13548,6 @@ } } }, - "Always Ask requires approval for every call. Follow Permissions uses the conversation's permission mode and safety checks." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "“始终询问”要求每次调用都由你批准;“跟随权限设置”则使用当前会话的权限模式和安全检查。" - } - } - } - }, "With SFTP" : { "localizations" : { "zh-Hans" : { @@ -12836,6 +13568,26 @@ } } }, + "Worked for %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作了%@" + } + } + } + }, + "Working for %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已工作%@" + } + } + } + }, "Workspace" : { "localizations" : { "zh-Hans" : { @@ -12866,6 +13618,16 @@ } } }, + "write" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "写入" + } + } + } + }, "Write" : { "localizations" : { "zh-Hans" : { @@ -12916,80 +13678,6 @@ } } }, - "%lldh %lldm" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld小时%2$lld分钟"}}}}, - "%lldh" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld小时"}}}}, - "%lldm %llds" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld分钟%2$lld秒"}}}}, - "%lldm" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld分钟"}}}}, - "%llds" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%lld秒"}}}}, - "Working for %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已工作%@"}}}}, - "Worked for %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"工作了%@"}}}}, - "You stopped after %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"你在%@后停止了"}}}}, - "ask" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"询问"}}}}, - "compare" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"比较"}}}}, - "create" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"创建"}}}}, - "delete" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"删除"}}}}, - "edit" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"编辑"}}}}, - "fetch" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"获取"}}}}, - "inspect" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"检查"}}}}, - "list" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"列出"}}}}, - "move" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"移动"}}}}, - "present" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"提交"}}}}, - "pull" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"拉取"}}}}, - "push" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"推送"}}}}, - "read" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"读取"}}}}, - "request" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"请求"}}}}, - "run" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"运行"}}}}, - "search" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"搜索"}}}}, - "suggest" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"生成建议"}}}}, - "update" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"更新"}}}}, - "wait for" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待"}}}}, - "write" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"写入"}}}}, - "change permissions for" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"更改权限"}}}}, - "use" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"使用"}}}}, - "a command" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"命令"}}}}, - "hosts" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"主机"}}}}, - "a plan" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"计划"}}}}, - "a question" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"问题"}}}}, - "the remote command" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远程命令"}}}}, - "a remote file" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"远程文件"}}}}, - "a scratch entry" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存条目"}}}}, - "a scratch file" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存文件"}}}}, - "scratch files" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存文件"}}}}, - "the scratch workspace" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"暂存工作区"}}}}, - "a secret" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"密钥信息"}}}}, - "the SSH command" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"SSH命令"}}}}, - "a URL" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"URL"}}}}, - "the web" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"网络"}}}}, - "Repeated tool action failed: %lld %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld次%2$@失败"}}}}, - "Repeated tool action interrupted: %lld %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$lld次%2$@已中断"}}}}, - "Tool action preparing: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在准备%1$@%2$@"}}}}, - "Tool action waiting: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待%1$@%2$@"}}}}, - "Tool action running: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在%1$@%2$@"}}}}, - "Tool action completed: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已%1$@%2$@"}}}}, - "Tool action denied: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未%1$@%2$@"}}}}, - "Tool action failed: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$@%2$@失败"}}}}, - "Tool action interrupted: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$@%2$@已中断"}}}}, - "Tool action target: %@ %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"%1$@(%2$@)"}}}}, - "Preparing to run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在准备在%2$@上运行“%1$@”"}}}}, - "Preparing to run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在准备运行“%@”"}}}}, - "Waiting to run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待在%2$@上运行“%1$@”"}}}}, - "Waiting to run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待运行“%@”"}}}}, - "Waiting to authenticate with %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"等待向%@进行身份验证"}}}}, - "Connecting to %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在连接%@"}}}}, - "Running “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在%2$@上运行“%1$@”"}}}}, - "Running “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"正在运行“%@”"}}}}, - "Ran “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已在%2$@上运行“%1$@”"}}}}, - "Ran “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已运行“%@”"}}}}, - "Did not run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未在%2$@上运行“%1$@”"}}}}, - "Did not run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未运行“%@”"}}}}, - "Failed to run “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未能在%2$@上运行“%1$@”"}}}}, - "Failed to run “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"未能运行“%@”"}}}}, - "Stopped waiting for “%@” on %@" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已停止等待%2$@上的“%1$@”"}}}}, - "Stopped waiting for “%@”" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"已停止等待“%@”"}}}}, - "File" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"文件"}}}}, - "Directory" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"目录"}}}}, - "Symlink" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"符号链接"}}}}, - "Critical" : {"localizations":{"zh-Hans":{"stringUnit":{"state":"translated","value":"严重"}}}}, "You said" : { "localizations" : { "zh-Hans" : { @@ -12999,6 +13687,16 @@ } } } + }, + "You stopped after %@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你在%@后停止了" + } + } + } } }, "version" : "1.2" diff --git a/TermindTests/AgentPersistenceTests.swift b/TermindTests/AgentPersistenceTests.swift index 92325d5b..11b3daef 100644 --- a/TermindTests/AgentPersistenceTests.swift +++ b/TermindTests/AgentPersistenceTests.swift @@ -94,6 +94,85 @@ struct AgentPersistenceTests { #expect(restored.first?.messages.first?.images == [image]) } + @Test + func contextAttachmentsRoundTripThroughConversationPersistence() async throws { + let configuration = localModelConfiguration( + "AgentContextAttachmentPersistenceTests", schema: HostPersistence.schema, + isStoredInMemoryOnly: true + ) + let container = try ModelContainer( + for: HostPersistence.schema, configurations: [configuration] + ) + let repository = SwiftDataAgentRunRepository(context: container.mainContext) + let attachment = AgentContextAttachment( + kind: .file, label: "manual.pdf", contentTypeIdentifier: "com.adobe.pdf", + suggestedFilename: "manual.pdf", data: Data([0x25, 0x50, 0x44, 0x46]) + ) + let message = AgentTranscriptMessage( + role: .user, text: "\n\n\nText\n", + contextAttachments: [attachment] + ) + let snapshot = AgentRunSnapshot( + conversationID: UUID(), state: .completed, + permissionMode: .askForApproval, messages: [message] + ) + + try await repository.save(snapshot) + let restored = try await repository.loadConversations() + + #expect(restored.first?.messages.first?.contextAttachments == [attachment]) + #expect(try container.mainContext.fetch( + FetchDescriptor() + ).count == 1) + + try await repository.deleteConversation(snapshot.conversationID) + #expect(try container.mainContext.fetch( + FetchDescriptor() + ).isEmpty) + } + + @Test + func schemaV2MigratesToV3WithEmptyAttachmentBacking() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("TermindAgentV3Migration-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + let storeURL = directory.appendingPathComponent("Termind.store") + let legacySchema = Schema(versionedSchema: TermindSchemaV2.self) + let conversationID = UUID() + + var legacyContainer: ModelContainer? = try ModelContainer( + for: legacySchema, + configurations: [ + localModelConfiguration("Migration", schema: legacySchema, url: storeURL) + ] + ) + legacyContainer?.mainContext.insert(PersistentAgentConversation( + id: conversationID, title: "Existing" + )) + try legacyContainer?.mainContext.save() + legacyContainer = nil + + let migrated = try ModelContainer( + for: HostPersistence.schema, + migrationPlan: TermindMigrationPlan.self, + configurations: [ + localModelConfiguration( + "Migration", schema: HostPersistence.schema, url: storeURL + ) + ] + ) + + #expect(try migrated.mainContext.fetch( + FetchDescriptor() + ).map(\.id) == [conversationID]) + #expect(try migrated.mainContext.fetch( + FetchDescriptor() + ).isEmpty) + } + @Test func displayReasoningRoundTripsRedactedWithoutProviderState() async throws { let configuration = localModelConfiguration( diff --git a/TermindTests/AgentSteeringTests.swift b/TermindTests/AgentSteeringTests.swift index b0bc0041..6d57787f 100644 --- a/TermindTests/AgentSteeringTests.swift +++ b/TermindTests/AgentSteeringTests.swift @@ -148,6 +148,28 @@ struct AgentSteeringTests { #expect(conversation.draft == " \n ") } + @Test + func anImageOnlySteeringMessageQueuesAndRestoresItsAttachment() throws { + let conversation = ConversationModel() + startRun(conversation) + let image = AgentImageAttachment( + label: "graph.png", mimeType: "image/png", data: Data([7, 8, 9]), + pixelWidth: 20, pixelHeight: 20 + ) + conversation.attach(ContextChip(kind: .image, label: image.label, image: image)) + + conversation.submitSteeringDraft() + + let pending = try #require(conversation.pendingSteering.first) + #expect(pending.draft.isEmpty) + #expect(pending.message.text.isEmpty) + #expect(pending.message.images == [image]) + #expect(conversation.attachments.isEmpty) + + conversation.stop() + #expect(conversation.attachments.first?.image == image) + } + /// Nothing is queued without a run to hand it to — an idle composer sends. @Test func thereIsNothingToSteerWithoutARun() { diff --git a/TermindTests/ChatComposerStateTests.swift b/TermindTests/ChatComposerStateTests.swift index 6ccf035a..86e500cf 100644 --- a/TermindTests/ChatComposerStateTests.swift +++ b/TermindTests/ChatComposerStateTests.swift @@ -73,6 +73,52 @@ struct ChatComposerStateTests { #expect(conversation.attachments.isEmpty) } + @Test + func aManualAttachmentMakesAnEmptyDraftSendable() { + let conversation = ConversationModel() + #expect(!conversation.canSendDraft) + + conversation.attach(ContextChip( + kind: .output, label: "Last lines", content: "service failed" + )) + #expect(conversation.canSendDraft) + + conversation.sendDraft() + + #expect(conversation.messages.last?.text == "") + #expect(conversation.messages.last?.contextChips.map(\.label) == ["Last lines"]) + #expect(conversation.transcript.last?.text.contains("service failed") == true) + #expect(conversation.transcript.last?.contextAttachments.first?.data + == Data("service failed".utf8)) + #expect(conversation.attachments.isEmpty) + } + + @Test + func imageOnlyTurnIsValidAndSuppliesItsFallbackTitle() { + let image = AgentImageAttachment( + label: "dashboard.png", mimeType: "image/png", data: Data([1, 2, 3]), + pixelWidth: 10, pixelHeight: 10 + ) + let conversation = ConversationModel() + conversation.attach(ContextChip(kind: .image, label: image.label, image: image)) + + conversation.sendDraft() + + #expect(conversation.transcript.last?.text == "") + #expect(conversation.transcript.last?.images == [image]) + #expect(conversation.derivedTitle == "dashboard.png") + } + + @Test + func legacyFileReferenceFallsBackToExtractedText() throws { + let text = "\n\n\nExtracted body\n" + let reference = try #require(ContextChip.visibleReferences(in: text).first) + + #expect(reference.attachment?.suggestedFilename == "manual extracted.txt") + #expect(reference.attachment?.isOriginalFile == false) + #expect(reference.attachment?.data == Data("Extracted body".utf8)) + } + @Test func activeFormKeysYieldToImeAndConsumeOnlyPlainReturnOrEscape() { #expect( diff --git a/TermindTests/ContextAttachmentImporterTests.swift b/TermindTests/ContextAttachmentImporterTests.swift index e7550990..9092cba8 100644 --- a/TermindTests/ContextAttachmentImporterTests.swift +++ b/TermindTests/ContextAttachmentImporterTests.swift @@ -18,6 +18,24 @@ struct ContextAttachmentImporterTests { #expect(chip.kind == .file) #expect(chip.content == "hello 世界") + #expect(chip.sourceAttachment?.data == "hello 世界".data(using: .utf16)) + #expect(chip.sourceAttachment?.suggestedFilename == url.lastPathComponent) + } + + @Test @MainActor + func pdfKeepsOriginalBytesAlongsideExtractedText() async throws { + let view = NSTextView(frame: NSRect(x: 0, y: 0, width: 300, height: 120)) + view.string = "Hello retained PDF" + let data = view.dataWithPDF(inside: view.bounds) + let url = temporaryFile(extension: "pdf") + defer { try? FileManager.default.removeItem(at: url) } + try data.write(to: url) + + let chip = try await ContextAttachmentImporter.importFile(at: url) + + #expect(chip.content.contains("Hello retained PDF")) + #expect(chip.sourceAttachment?.data == data) + #expect(chip.sourceAttachment?.contentTypeIdentifier == "com.adobe.pdf") } @Test @@ -60,6 +78,20 @@ struct ContextAttachmentImporterTests { } } + @Test + func materializedAttachmentCannotEscapeItsPrivateDirectory() throws { + let attachment = AgentContextAttachment( + kind: .file, label: "unsafe", contentTypeIdentifier: "public.plain-text", + suggestedFilename: "../../secret.txt", data: Data("safe".utf8) + ) + let url = try ContextAttachmentOpener.materialize(attachment) + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + + #expect(url.lastPathComponent == "secret.txt") + #expect(url.deletingLastPathComponent().lastPathComponent == attachment.id.uuidString) + #expect(try Data(contentsOf: url) == attachment.data) + } + @Test @MainActor func rejectsImageOnlyPDFWithoutRunningOCR() async throws { let bitmap = NSBitmapImageRep(