diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift index e0b2b34b8ac0..a85f0cb7a5ef 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ArtifactByteReader.swift @@ -7,6 +7,10 @@ import UniformTypeIdentifiers /// Authorization is intentionally outside this type. Callers must scope-check the /// requested path before invoking these methods. public struct ArtifactByteReader: Sendable { + /// Maximum immediate children returned by one directory-list request. + public static let maximumDirectoryEntryCount = 500 + private static let utf8SniffByteCount = 8 * 1024 + /// Filesystem/decoder failures surfaced by artifact RPC handlers. public enum Error: Swift.Error, Sendable { /// The scoped path no longer exists or cannot be statted. @@ -96,37 +100,45 @@ public struct ArtifactByteReader: Sendable { ) } - /// Lists up to 500 immediate children for an already-authorized directory. + /// Lists up to ``maximumDirectoryEntryCount`` immediate children for an + /// already-authorized directory. + /// + /// One readdir pass collects child names; per-child filesystem metadata is + /// read only for the capped entries that the listing actually returns. public func list(path: String) throws -> ChatArtifactDirectoryListing { let stat = try stat(path: path) guard stat.isDirectory else { throw Error.fileNotFound } - let url = URL(fileURLWithPath: path, isDirectory: true) - let entries = try FileManager.default.contentsOfDirectory( - at: url, - includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], - options: [] - ) - let listed = try entries - .sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending } - .prefix(500) - .map { entry -> ChatArtifactDirectoryEntry in + let names = try FileManager.default.contentsOfDirectory(atPath: path) + let sortedNames = names.sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } + let directoryURL = URL(fileURLWithPath: path, isDirectory: true) + let listed = try sortedNames + .prefix(Self.maximumDirectoryEntryCount) + .map { name -> ChatArtifactDirectoryEntry in + let entry = directoryURL.appendingPathComponent(name) let values = try entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) let isDirectory = values.isDirectory ?? false return ChatArtifactDirectoryEntry( - name: entry.lastPathComponent, + name: name, isDirectory: isDirectory, size: Int64(values.fileSize ?? 0), kind: kind(path: entry.path, isDirectory: isDirectory) ) } - return ChatArtifactDirectoryListing(entries: listed) + return ChatArtifactDirectoryListing( + entries: listed, + isTruncated: names.count > Self.maximumDirectoryEntryCount + ) } - /// Infers preview category from a path extension and directory flag. + /// Infers preview category from a path extension and a bounded UTF-8 sniff. public func kind(path: String, isDirectory: Bool) -> ChatArtifactKind { if isDirectory { return .directory } - guard let type = UTType(filenameExtension: URL(fileURLWithPath: path).pathExtension) else { - return .binary + let fileExtension = URL(fileURLWithPath: path).pathExtension + let type = fileExtension.isEmpty ? nil : UTType(filenameExtension: fileExtension) + guard let type, !type.isDynamic else { + return isUTF8Text(path: path) ? .text : .binary } if type.conforms(to: .image) { return .image } if type.conforms(to: .text) || type.conforms(to: .sourceCode) || type.conforms(to: .json) { @@ -135,6 +147,80 @@ public struct ArtifactByteReader: Sendable { return .binary } + private func isUTF8Text(path: String) -> Bool { + guard let handle = FileHandle(forReadingAtPath: path) else { + return false + } + defer { try? handle.close() } + let bytes: Data + do { + bytes = try handle.read(upToCount: Self.utf8SniffByteCount + 1) ?? Data() + } catch { + return false + } + let sample = Data(bytes.prefix(Self.utf8SniffByteCount)) + if String(data: sample, encoding: .utf8) != nil { + return true + } + guard bytes.count > Self.utf8SniffByteCount else { + return false + } + return hasValidUTF8PrefixEndingInPartialScalar(sample) + } + + private func hasValidUTF8PrefixEndingInPartialScalar(_ data: Data) -> Bool { + let bytes = Array(data) + guard !bytes.isEmpty else { return false } + let earliestCandidate = max(0, bytes.count - 4) + for start in stride(from: bytes.count - 1, through: earliestCandidate, by: -1) { + guard let expectedLength = utf8ScalarLength(leadingByte: bytes[start]) else { + continue + } + let actualLength = bytes.count - start + guard actualLength < expectedLength, + utf8PartialScalarBytesAreValid(Array(bytes[start...])) else { + continue + } + let prefix = Data(bytes[.. Int? { + switch leadingByte { + case 0xC2...0xDF: + return 2 + case 0xE0...0xEF: + return 3 + case 0xF0...0xF4: + return 4 + default: + return nil + } + } + + private func utf8PartialScalarBytesAreValid(_ bytes: [UInt8]) -> Bool { + guard let leadingByte = bytes.first else { return false } + for byte in bytes.dropFirst() where byte & 0xC0 != 0x80 { + return false + } + guard bytes.count > 1 else { return true } + let firstContinuation = bytes[1] + switch leadingByte { + case 0xE0: + return firstContinuation >= 0xA0 + case 0xED: + return firstContinuation <= 0x9F + case 0xF0: + return firstContinuation >= 0x90 + case 0xF4: + return firstContinuation <= 0x8F + default: + return true + } + } + private func attributes(path: String) throws -> [FileAttributeKey: Any] { do { return try FileManager.default.attributesOfItem(atPath: path) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift index bfa595dea863..ba0f572766bc 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactDirectoryListing.swift @@ -2,11 +2,35 @@ public struct ChatArtifactDirectoryListing: Sendable, Equatable, Codable { /// Directory entries sorted by name. public let entries: [ChatArtifactDirectoryEntry] + /// Whether additional immediate children were omitted by the server cap. + public let isTruncated: Bool /// Creates a directory listing. /// - /// - Parameter entries: Directory entries sorted by name. - public init(entries: [ChatArtifactDirectoryEntry]) { + /// - Parameters: + /// - entries: Directory entries sorted by name. + /// - isTruncated: Whether the server omitted additional entries. + public init(entries: [ChatArtifactDirectoryEntry], isTruncated: Bool = false) { self.entries = entries + self.isTruncated = isTruncated + } + + private enum CodingKeys: String, CodingKey { + case entries + case isTruncated = "is_truncated" + } + + /// Decodes listings from current and legacy hosts. + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + entries = try container.decode([ChatArtifactDirectoryEntry].self, forKey: .entries) + isTruncated = try container.decodeIfPresent(Bool.self, forKey: .isTruncated) ?? false + } + + /// Encodes the capped-list metadata for mobile clients. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(entries, forKey: .entries) + try container.encode(isTruncated, forKey: .isTruncated) } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift new file mode 100644 index 000000000000..045bfe15de82 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryBuilder.swift @@ -0,0 +1,179 @@ +import Foundation + +/// Builds stat-enriched, append-only pages from one transcript index snapshot. +public struct ChatArtifactGalleryBuilder: Sendable { + /// Creates a gallery page builder. + public init() {} + + /// Builds one sectioned or flat search page. + /// + /// - Parameters: + /// - sessionID: Session represented by the artifact index. + /// - items: De-duplicated transcript artifact references. + /// - orderedItems: Optional generation-cached stable ordering of `items`. + /// - generation: Stable snapshot generation carried by page cursors. + /// - cursor: Per-section positions after which paging continues. + /// - pageSize: Maximum entries to stat and include per section. + /// - query: Optional basename or path search. + /// - includeDirectories: Whether directory references are eligible for + /// rows. This defaults to `false` for clients without folder capability. + /// - Returns: One gallery page with filesystem metadata. + public func page( + sessionID: String, + items: [ChatArtifactIndexedReference], + orderedItems: [ChatArtifactIndexedReference]? = nil, + generation: String, + cursor: ChatArtifactGalleryCursor?, + pageSize: Int, + query: String?, + includeDirectories: Bool = false + ) -> ChatArtifactGalleryPage { + if let cursor, cursor.generation != generation { + return ChatArtifactGalleryPage( + sessionID: sessionID, + generation: generation, + requiresPagingRestart: true + ) + } + let ordering = ChatArtifactGalleryOrdering() + let stableItems = orderedItems ?? ordering.sorted(items) + let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) + let isSearch = normalizedQuery?.isEmpty == false + let createdCandidates: [ChatArtifactIndexedReference] + let attachedCandidates: [ChatArtifactIndexedReference] + let referencedCandidates: [ChatArtifactIndexedReference] + if let normalizedQuery, !normalizedQuery.isEmpty { + createdCandidates = [] + attachedCandidates = [] + referencedCandidates = ordering.matching(stableItems, query: normalizedQuery) + } else { + createdCandidates = stableItems.filter { $0.provenance == .created } + attachedCandidates = stableItems.filter { $0.provenance == .attached } + referencedCandidates = stableItems.filter { $0.provenance == .referenced } + } + let count = max(1, pageSize) + let starts = pageStarts( + cursor: cursor, + created: createdCandidates, + attached: attachedCandidates, + referenced: referencedCandidates, + ordering: ordering + ) + // Sequential fill: a page extends the grouped list strictly at its + // bottom (created, then attached, then referenced), so a + // scroll-triggered load can never insert rows into a group the user + // has already scrolled past. + var remaining = count + let pageCreated = Array(createdCandidates.dropFirst(starts.created).prefix(remaining)) + remaining -= pageCreated.count + let pageAttached = Array(attachedCandidates.dropFirst(starts.attached).prefix(remaining)) + remaining -= pageAttached.count + let pageReferenced = Array(referencedCandidates.dropFirst(starts.referenced).prefix(remaining)) + let nextCreatedOffset = starts.created + pageCreated.count + let nextAttachedOffset = starts.attached + pageAttached.count + let nextReferencedOffset = starts.referenced + pageReferenced.count + let nextCursor: String? + if nextCreatedOffset < createdCandidates.count + || nextAttachedOffset < attachedCandidates.count + || nextReferencedOffset < referencedCandidates.count { + let last = pageReferenced.last + nextCursor = try? ChatArtifactGalleryCursor( + generation: generation, + seq: last?.lastReferencedSeq ?? cursor?.seq ?? .max, + path: last?.path ?? cursor?.path ?? "", + createdOffset: nextCreatedOffset, + attachedOffset: nextAttachedOffset, + referencedOffset: nextReferencedOffset + ).token() + } else { + nextCursor = nil + } + + return ChatArtifactGalleryPage( + sessionID: sessionID, + created: isSearch ? [] : statItems(pageCreated, includeDirectories: includeDirectories), + createdTotal: createdCandidates.count, + attached: isSearch ? [] : statItems(pageAttached, includeDirectories: includeDirectories), + attachedTotal: attachedCandidates.count, + referenced: statItems(pageReferenced, includeDirectories: includeDirectories), + referencedTotal: referencedCandidates.count, + nextCursor: nextCursor, + generation: generation + ) + } + + private func pageStarts( + cursor: ChatArtifactGalleryCursor?, + created: [ChatArtifactIndexedReference], + attached: [ChatArtifactIndexedReference], + referenced: [ChatArtifactIndexedReference], + ordering: ChatArtifactGalleryOrdering + ) -> (created: Int, attached: Int, referenced: Int) { + guard let cursor else { return (0, 0, 0) } + if let createdOffset = cursor.createdOffset, + let attachedOffset = cursor.attachedOffset, + let referencedOffset = cursor.referencedOffset { + return ( + min(max(0, createdOffset), created.count), + min(max(0, attachedOffset), attached.count), + min(max(0, referencedOffset), referenced.count) + ) + } + let remaining = ordering.items(referenced, strictlyAfter: cursor) + return (created.count, attached.count, referenced.count - remaining.count) + } + + /// Counts immediate children for a gallery directory row without sorting + /// or per-entry metadata, stopping at the shared listing limit so the cost + /// never scales past the cap for large folders. + private func directoryChildCount(path: String) -> (count: Int, isCapped: Bool)? { + guard let enumerator = FileManager.default.enumerator( + at: URL(fileURLWithPath: path, isDirectory: true), + includingPropertiesForKeys: [], + options: [.skipsSubdirectoryDescendants] + ) else { + return nil + } + var count = 0 + while enumerator.nextObject() != nil { + count += 1 + if count > ArtifactByteReader.maximumDirectoryEntryCount { + return (count: ArtifactByteReader.maximumDirectoryEntryCount, isCapped: true) + } + } + return (count: count, isCapped: false) + } + + private func statItems( + _ references: [ChatArtifactIndexedReference], + includeDirectories: Bool + ) -> [ChatArtifactGalleryItem] { + let reader = ArtifactByteReader() + return references.compactMap { reference in + do { + let stat = try reader.stat(path: reference.path) + guard includeDirectories || !stat.isDirectory else { return nil } + let children = stat.isDirectory ? directoryChildCount(path: reference.path) : nil + return ChatArtifactGalleryItem( + path: reference.path, + kind: stat.kind, + displayName: URL(fileURLWithPath: reference.path).lastPathComponent, + size: stat.size, + modifiedAt: stat.modifiedAt, + exists: stat.exists, + childCount: children?.count, + childCountIsCapped: children?.isCapped ?? false, + provenance: reference.provenance + ) + } catch { + return ChatArtifactGalleryItem( + path: reference.path, + kind: reader.kind(path: reference.path, isDirectory: false), + displayName: URL(fileURLWithPath: reference.path).lastPathComponent, + exists: false, + provenance: reference.provenance + ) + } + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift new file mode 100644 index 000000000000..749197c2c948 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryClassifier.swift @@ -0,0 +1,121 @@ +import Foundation +import UniformTypeIdentifiers + +/// Classifies gallery artifacts into the dedicated sheet filter buckets. +public struct ChatArtifactGalleryClassifier: Sendable { + private let logExtensions: Set = ["log", "out"] + private let documentExtensions: Set = [ + "doc", "docx", "key", "md", "markdown", "mdown", "mkd", "numbers", + "odp", "ods", "odt", "pages", "pdf", "ppt", "pptx", "rtf", "txt", "xls", "xlsx", + ] + + /// Creates an artifact classifier. + public init() {} + + /// Returns the artifact's dedicated filter bucket, if it has one. + /// + /// Image and directory kinds take precedence over filename extensions. + /// Artifacts returning `nil` remain visible under ``ChatArtifactGalleryFilter/all``. + /// + /// - Parameters: + /// - kind: Preview kind assigned by the artifact host. + /// - path: Artifact path whose extension supplements the preview kind. + /// - Returns: A dedicated filter bucket, or `nil` for All-only artifacts. + public func filter( + for kind: ChatArtifactKind, + path: String + ) -> ChatArtifactGalleryFilter? { + switch kind { + case .image: + return .images + case .directory: + return .folders + case .text, .binary: + break + } + + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + guard !pathExtension.isEmpty else { return nil } + if let type = UTType(filenameExtension: pathExtension), + type.conforms(to: .sourceCode) { + return .code + } + if logExtensions.contains(pathExtension) { + return .logs + } + if documentExtensions.contains(pathExtension) { + return .docs + } + return nil + } + + /// Returns the artifact's dedicated filter bucket, if it has one. + /// + /// - Parameter item: Gallery item to classify. + /// - Returns: A dedicated filter bucket, or `nil` for All-only artifacts. + public func filter(for item: ChatArtifactGalleryItem) -> ChatArtifactGalleryFilter? { + filter(for: item.kind, path: item.path) + } + + /// Returns the shared glyph and tint for an artifact gallery row. + /// + /// PDF, Office, Markdown, plain-text, and otherwise generic files share + /// one document glyph. Only images, source code, logs, and folders use + /// distinct presentation. + /// + /// - Parameters: + /// - kind: Preview kind assigned by the artifact host. + /// - path: Artifact path whose extension supplements the preview kind. + /// - Returns: Glyph presentation for list, grid, and folder-browser placeholders. + public func glyphPresentation( + for kind: ChatArtifactKind, + path: String + ) -> ChatArtifactGalleryGlyphPresentation { + switch kind { + case .image: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "photo", + tint: .secondary + ) + case .directory: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "folder", + tint: .accent + ) + case .text, .binary: + break + } + switch filter(for: kind, path: path) { + case .code: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) + case .logs: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "text.alignleft", + tint: .accent + ) + case .images: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "photo", + tint: .secondary + ) + case .folders: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "folder", + tint: .accent + ) + case .docs, .all: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "doc.text", + tint: .accent + ) + case nil: + return ChatArtifactGalleryGlyphPresentation( + systemImageName: "doc.text", + tint: kind == .text ? .accent : .secondary + ) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift index a66c4909ffbb..981217f37c88 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryCursor.swift @@ -8,12 +8,34 @@ public struct ChatArtifactGalleryCursor: Sendable, Equatable, Codable { public let seq: Int /// Path tie-breaker of the page's final row. public let path: String + let createdOffset: Int? + let attachedOffset: Int? + let referencedOffset: Int? /// Creates a stable cursor. public init(generation: String, seq: Int, path: String) { self.generation = generation self.seq = seq self.path = path + createdOffset = nil + attachedOffset = nil + referencedOffset = nil + } + + init( + generation: String, + seq: Int, + path: String, + createdOffset: Int, + attachedOffset: Int, + referencedOffset: Int + ) { + self.generation = generation + self.seq = seq + self.path = path + self.createdOffset = createdOffset + self.attachedOffset = attachedOffset + self.referencedOffset = referencedOffset } /// Encodes this cursor as an opaque RPC token. diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift new file mode 100644 index 000000000000..50bcfcfdb8b9 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPager.swift @@ -0,0 +1,60 @@ +/// Sequentially loads referenced gallery pages for complete filtering and sorting. +public struct ChatArtifactGalleryEagerPager: Sendable { + /// Default maximum referenced rows retained during eager loading. + public static let defaultMaximumReferencedRows = 2_000 + + private let maximumReferencedRows: Int + + /// Creates an eager pager. + /// + /// - Parameter maximumReferencedRows: Maximum referenced rows to retain. + public init( + maximumReferencedRows: Int = Self.defaultMaximumReferencedRows + ) { + self.maximumReferencedRows = max(0, maximumReferencedRows) + } + + /// Loads cursor pages sequentially until exhausted or capped. + /// + /// A repeated cursor ends the loop defensively while preserving that cursor + /// for a later retry rather than issuing the same request indefinitely. + /// + /// - Parameters: + /// - initialSnapshot: First page or already accumulated gallery snapshot. + /// - fetchPage: Async operation for one opaque cursor. + /// - Returns: The accumulated snapshot and cap status. + /// - Throws: The first page-loading error or cancellation. + public func loadRemaining( + from initialSnapshot: ChatArtifactGallerySnapshot, + fetchPage: @escaping @Sendable (_ cursor: String) async throws -> ChatArtifactGalleryPage + ) async throws -> ChatArtifactGalleryEagerPagingResult { + var snapshot = initialSnapshot.limitingReferenced(to: maximumReferencedRows) + var seenCursors: Set = [] + var truncatedRows = initialSnapshot.referenced.count > snapshot.referenced.count + + while let cursor = snapshot.nextCursor, + snapshot.referenced.count < maximumReferencedRows, + seenCursors.insert(cursor).inserted { + try Task.checkCancellation() + let page = try await fetchPage(cursor) + try Task.checkCancellation() + if page.requiresPagingRestart { + return ChatArtifactGalleryEagerPagingResult( + snapshot: initialSnapshot, + reachedSafetyCap: false, + requiresPagingRestart: true + ) + } + let appended = snapshot.appending(page) + truncatedRows = truncatedRows || appended.referenced.count > maximumReferencedRows + snapshot = appended.limitingReferenced(to: maximumReferencedRows) + } + + let hasMoreAtCap = snapshot.referenced.count >= maximumReferencedRows + && snapshot.nextCursor != nil + return ChatArtifactGalleryEagerPagingResult( + snapshot: snapshot, + reachedSafetyCap: truncatedRows || hasMoreAtCap + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift new file mode 100644 index 000000000000..d56af4301de0 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryEagerPagingResult.swift @@ -0,0 +1,25 @@ +/// The completed outcome of eagerly loading referenced gallery pages. +public struct ChatArtifactGalleryEagerPagingResult: Sendable, Equatable { + /// Accumulated rows and the cursor that remains after loading. + public let snapshot: ChatArtifactGallerySnapshot + /// Whether loading stopped because more than the configured row cap exists. + public let reachedSafetyCap: Bool + /// Whether the host rejected an obsolete generation cursor. + public let requiresPagingRestart: Bool + + /// Creates an eager-paging result. + /// + /// - Parameters: + /// - snapshot: Accumulated rows and remaining cursor. + /// - reachedSafetyCap: Whether the row cap prevented complete loading. + /// - requiresPagingRestart: Whether paging must restart from a first page. + public init( + snapshot: ChatArtifactGallerySnapshot, + reachedSafetyCap: Bool, + requiresPagingRestart: Bool = false + ) { + self.snapshot = snapshot + self.reachedSafetyCap = reachedSafetyCap + self.requiresPagingRestart = requiresPagingRestart + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryFilter.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryFilter.swift new file mode 100644 index 000000000000..c177a4db5de6 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryFilter.swift @@ -0,0 +1,15 @@ +/// A sheet-wide artifact-kind filter applied within every gallery group. +public enum ChatArtifactGalleryFilter: String, CaseIterable, Sendable, Equatable { + /// Includes every artifact, including kinds without a dedicated filter. + case all + /// Includes image artifacts. + case images + /// Includes source-code artifacts. + case code + /// Includes log and plain-text artifacts. + case logs + /// Includes document artifacts. + case docs + /// Includes directory artifacts. + case folders +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphPresentation.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphPresentation.swift new file mode 100644 index 000000000000..721ded3e58fb --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphPresentation.swift @@ -0,0 +1,18 @@ +/// Shared glyph and tint presentation for one artifact row or tile. +public struct ChatArtifactGalleryGlyphPresentation: Sendable, Equatable { + /// SF Symbol rendered when an image thumbnail is unavailable or inapplicable. + public let systemImageName: String + + /// Semantic tint applied to the symbol. + public let tint: ChatArtifactGalleryGlyphTint + + /// Creates a shared artifact glyph presentation. + /// + /// - Parameters: + /// - systemImageName: SF Symbol rendered by list and grid consumers. + /// - tint: Semantic symbol tint. + public init(systemImageName: String, tint: ChatArtifactGalleryGlyphTint) { + self.systemImageName = systemImageName + self.tint = tint + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphTint.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphTint.swift new file mode 100644 index 000000000000..f1ba1858773e --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGlyphTint.swift @@ -0,0 +1,8 @@ +/// Semantic tint shared by every artifact gallery glyph consumer. +public enum ChatArtifactGalleryGlyphTint: Sendable, Equatable { + /// The gallery's blue interactive accent. + case accent + + /// A subdued placeholder for images and otherwise generic binary files. + case secondary +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroup.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroup.swift new file mode 100644 index 000000000000..b07872a96f29 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroup.swift @@ -0,0 +1,23 @@ +/// One immutable, filtered and sorted provenance group. +public struct ChatArtifactGalleryGroup: Sendable, Equatable, Identifiable { + /// The group's stable provenance identity. + public let kind: ChatArtifactGalleryGroupKind + /// Visible rows in the group's selected ordering. + public let items: [ChatArtifactGalleryItem] + + /// Stable group identity. + public var id: ChatArtifactGalleryGroupKind { kind } + + /// Creates a gallery group. + /// + /// - Parameters: + /// - kind: Stable provenance identity. + /// - items: Visible rows in selected order. + public init( + kind: ChatArtifactGalleryGroupKind, + items: [ChatArtifactGalleryItem] + ) { + self.kind = kind + self.items = items + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroupKind.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroupKind.swift new file mode 100644 index 000000000000..3f8211fd8067 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryGroupKind.swift @@ -0,0 +1,9 @@ +/// The fixed provenance group organization of the artifact gallery. +public enum ChatArtifactGalleryGroupKind: String, CaseIterable, Sendable, Equatable { + /// Files created by the agent. + case created + /// Files attached by the user. + case attached + /// Other files referenced by the session. + case referenced +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift index 4259de2f73f2..8faa746a0cd5 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryItem.swift @@ -14,6 +14,10 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl public let modifiedAt: Date? /// Whether the path existed when this page was served. public let exists: Bool + /// Immediate child count for a directory, when listing succeeded. + public let childCount: Int? + /// Whether ``childCount`` is the listing cap rather than the exact count. + public let childCountIsCapped: Bool /// Transcript provenance after precedence-based de-duplication. public let provenance: ChatArtifactProvenance @@ -28,6 +32,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl size: Int64? = nil, modifiedAt: Date? = nil, exists: Bool = true, + childCount: Int? = nil, + childCountIsCapped: Bool = false, provenance: ChatArtifactProvenance = .referenced ) { self.path = path @@ -36,6 +42,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl self.size = size self.modifiedAt = modifiedAt self.exists = exists + self.childCount = childCount + self.childCountIsCapped = childCountIsCapped self.provenance = provenance } @@ -46,6 +54,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl case size case modifiedAt = "modified_at" case exists + case childCount = "child_count" + case childCountIsCapped = "child_count_is_capped" case provenance } @@ -62,6 +72,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl modifiedAt = try? container.decode(Date.self, forKey: .modifiedAt) } exists = (try? container.decode(Bool.self, forKey: .exists)) ?? true + childCount = try? container.decode(Int.self, forKey: .childCount) + childCountIsCapped = (try? container.decode(Bool.self, forKey: .childCountIsCapped)) ?? false provenance = (try? container.decode(ChatArtifactProvenance.self, forKey: .provenance)) ?? .referenced } @@ -75,6 +87,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl try container.encode(modifiedAt.timeIntervalSince1970, forKey: .modifiedAt) } try container.encode(exists, forKey: .exists) + try container.encodeIfPresent(childCount, forKey: .childCount) + try container.encode(childCountIsCapped, forKey: .childCountIsCapped) try container.encode(provenance, forKey: .provenance) } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryLiveRefreshState.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryLiveRefreshState.swift new file mode 100644 index 000000000000..e68f699822ae --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryLiveRefreshState.swift @@ -0,0 +1,57 @@ +/// Defers gallery generation changes while the user is reading away from the top. +public struct ChatArtifactGalleryLiveRefreshState: Sendable, Equatable { + private var pendingSnapshot: ChatArtifactGallerySnapshot? + + /// Number of unseen paths waiting for explicit application. + public private(set) var pendingNewFileCount = 0 + + /// Creates an empty live-refresh state. + public init() {} + + /// Receives a fresh first page and decides whether it is safe to show now. + /// + /// - Parameters: + /// - fresh: First page from the latest session generation. + /// - displayed: Snapshot currently rendered by the gallery. + /// - isAtTopOrFits: Whether inserting leading rows cannot disturb a reading position. + /// - Returns: A reconciled snapshot to display immediately, or `nil` when a pill should be shown. + public mutating func receive( + fresh: ChatArtifactGallerySnapshot, + displayed: ChatArtifactGallerySnapshot, + isAtTopOrFits: Bool + ) -> ChatArtifactGallerySnapshot? { + guard fresh.generation != displayed.generation else { return nil } + let displayedPaths = Set((displayed.created + displayed.attached + displayed.referenced).map(\.path)) + let freshPaths = Set((fresh.created + fresh.attached + fresh.referenced).map(\.path)) + let newFileCount = freshPaths.subtracting(displayedPaths).count + let reconciled = displayed.reconciling(withFreshFirstPage: fresh) + + guard !isAtTopOrFits, newFileCount > 0 else { + pendingSnapshot = nil + pendingNewFileCount = 0 + return reconciled + } + pendingSnapshot = fresh + pendingNewFileCount = newFileCount + return nil + } + + /// Applies the newest deferred first page and clears the pending pill. + /// + /// - Parameter displayed: Snapshot currently rendered by the gallery. + /// - Returns: The reconciled snapshot, or `nil` when no refresh is pending. + public mutating func applyPending( + to displayed: ChatArtifactGallerySnapshot + ) -> ChatArtifactGallerySnapshot? { + guard let pendingSnapshot else { return nil } + self.pendingSnapshot = nil + pendingNewFileCount = 0 + return displayed.reconciling(withFreshFirstPage: pendingSnapshot) + } + + /// Drops a deferred refresh when the session or scope changes. + public mutating func reset() { + pendingSnapshot = nil + pendingNewFileCount = 0 + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift index 058f73133291..2207ef41a908 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrdering.swift @@ -45,10 +45,17 @@ public struct ChatArtifactGalleryOrdering: Sendable { public func search( _ items: [ChatArtifactIndexedReference], query: String + ) -> [ChatArtifactIndexedReference] { + matching(sorted(items), query: query) + } + + func matching( + _ orderedItems: [ChatArtifactIndexedReference], + query: String ) -> [ChatArtifactIndexedReference] { let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !needle.isEmpty else { return sorted(items) } - return sorted(items).filter { item in + guard !needle.isEmpty else { return orderedItems } + return orderedItems.filter { item in item.path.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive]) != nil || URL(fileURLWithPath: item.path).lastPathComponent.range( of: needle, diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrderingCache.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrderingCache.swift new file mode 100644 index 000000000000..bd7ef57fac96 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryOrderingCache.swift @@ -0,0 +1,59 @@ +/// Reuses the stable artifact ordering for repeated pages of one index generation. +public actor ChatArtifactGalleryOrderingCache { + private struct Key: Hashable { + let indexID: String + let generation: String + } + + private struct Entry { + let items: [ChatArtifactIndexedReference] + let access: UInt64 + } + + private let maximumEntryCount: Int + private var entries: [Key: Entry] = [:] + private var accessCounter: UInt64 = 0 + + /// Creates a bounded ordering cache. + /// + /// - Parameter maximumEntryCount: Maximum index generations retained. + public init(maximumEntryCount: Int = 8) { + self.maximumEntryCount = max(0, maximumEntryCount) + } + + /// Returns one newest-first ordering, reusing it for the same index generation. + /// + /// A new generation for an index invalidates that index's previous ordering. + /// + /// - Parameters: + /// - items: De-duplicated references from the authoritative index snapshot. + /// - indexID: Stable identity of the transcript index. + /// - generation: File generation represented by `items`. + /// - Returns: References in stable gallery order. + public func ordered( + _ items: [ChatArtifactIndexedReference], + indexID: String, + generation: String + ) -> [ChatArtifactIndexedReference] { + let key = Key(indexID: indexID, generation: generation) + accessCounter &+= 1 + if let cached = entries[key] { + entries[key] = Entry(items: cached.items, access: accessCounter) + return cached.items + } + + entries = entries.filter { $0.key.indexID != indexID } + let ordered = ChatArtifactGalleryOrdering().sorted(items) + guard maximumEntryCount > 0 else { return ordered } + entries[key] = Entry(items: ordered, access: accessCounter) + evictIfNeeded() + return ordered + } + + private func evictIfNeeded() { + while entries.count > maximumEntryCount, + let leastRecent = entries.min(by: { $0.value.access < $1.value.access })?.key { + entries.removeValue(forKey: leastRecent) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift index 35ab59b8903e..a525f3bd4f76 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPage.swift @@ -2,56 +2,96 @@ public struct ChatArtifactGalleryPage: Sendable, Equatable, Codable { /// Session that authorizes every returned path. public let sessionID: String - /// Complete created paths, present only on the first sectioned page. + /// Created paths in this page. public let created: [ChatArtifactGalleryItem] - /// Complete attachment paths, present only on the first sectioned page. + /// Total created paths in the stable snapshot. + public let createdTotal: Int + /// Attachment paths in this page. public let attached: [ChatArtifactGalleryItem] + /// Total attachment paths in the stable snapshot. + public let attachedTotal: Int /// Referenced page, or a flat all-provenance search-result page. public let referenced: [ChatArtifactGalleryItem] /// Total referenced count, or total search-result count for a query. public let referencedTotal: Int - /// Opaque cursor for the next append-only page. + /// Opaque cursor for the next append-only page across all sections. public let nextCursor: String? /// Snapshot generation that served this response. public let generation: String + /// Whether the request cursor belongs to an obsolete generation. + public let requiresPagingRestart: Bool /// Creates one gallery response page. public init( sessionID: String, created: [ChatArtifactGalleryItem] = [], + createdTotal: Int? = nil, attached: [ChatArtifactGalleryItem] = [], + attachedTotal: Int? = nil, referenced: [ChatArtifactGalleryItem] = [], referencedTotal: Int = 0, nextCursor: String? = nil, - generation: String = "" + generation: String = "", + requiresPagingRestart: Bool = false ) { self.sessionID = sessionID self.created = created + self.createdTotal = createdTotal ?? created.count self.attached = attached + self.attachedTotal = attachedTotal ?? attached.count self.referenced = referenced self.referencedTotal = referencedTotal self.nextCursor = nextCursor self.generation = generation + self.requiresPagingRestart = requiresPagingRestart } private enum CodingKeys: String, CodingKey { case sessionID = "session_id" case created + case createdTotal = "created_total" case attached + case attachedTotal = "attached_total" case referenced case referencedTotal = "referenced_total" case nextCursor = "next_cursor" case generation + case requiresPagingRestart = "requires_paging_restart" } public init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) sessionID = (try? container.decode(String.self, forKey: .sessionID)) ?? "" created = (try? container.decode([ChatArtifactGalleryItem].self, forKey: .created)) ?? [] + createdTotal = (try? container.decode(Int.self, forKey: .createdTotal)) ?? created.count attached = (try? container.decode([ChatArtifactGalleryItem].self, forKey: .attached)) ?? [] + attachedTotal = (try? container.decode(Int.self, forKey: .attachedTotal)) ?? attached.count referenced = (try? container.decode([ChatArtifactGalleryItem].self, forKey: .referenced)) ?? [] referencedTotal = (try? container.decode(Int.self, forKey: .referencedTotal)) ?? referenced.count nextCursor = try? container.decode(String.self, forKey: .nextCursor) generation = (try? container.decode(String.self, forKey: .generation)) ?? "" + requiresPagingRestart = (try? container.decode(Bool.self, forKey: .requiresPagingRestart)) ?? false + } + + /// Returns a compatibility view with directory rows removed. + /// + /// New clients use this when connected to a host that does not advertise + /// folder gallery support. + public func excludingDirectories() -> ChatArtifactGalleryPage { + let filteredCreated = created.filter { $0.kind != .directory } + let filteredAttached = attached.filter { $0.kind != .directory } + let filteredReferenced = referenced.filter { $0.kind != .directory } + return ChatArtifactGalleryPage( + sessionID: sessionID, + created: filteredCreated, + createdTotal: max(0, createdTotal - (created.count - filteredCreated.count)), + attached: filteredAttached, + attachedTotal: max(0, attachedTotal - (attached.count - filteredAttached.count)), + referenced: filteredReferenced, + referencedTotal: max(0, referencedTotal - (referenced.count - filteredReferenced.count)), + nextCursor: nextCursor, + generation: generation, + requiresPagingRestart: requiresPagingRestart + ) } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift new file mode 100644 index 000000000000..1ef806b71bed --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGalleryPresentation.swift @@ -0,0 +1,106 @@ +import Foundation + +/// A pure sheet-wide filter and sort projection over fixed gallery groups. +public struct ChatArtifactGalleryPresentation: Sendable, Equatable { + /// Created, Attached, and Referenced groups in their fixed display order. + public let groups: [ChatArtifactGalleryGroup] + + /// Whether every projected group has no visible rows. + public var isEmpty: Bool { groups.allSatisfy(\.items.isEmpty) } + + /// Creates a grouped presentation without changing the source snapshot. + /// + /// - Parameters: + /// - snapshot: Accumulated gallery rows to project. + /// - filter: Sheet-wide kind filter. + /// - sort: Ordering applied independently within each group. + /// - includesMissingFiles: Whether rows absent from the Mac remain visible. + /// - classifier: Classifier used for extension-based buckets. + public init( + snapshot: ChatArtifactGallerySnapshot, + filter: ChatArtifactGalleryFilter = .all, + sort: ChatArtifactGallerySort = .recent, + includesMissingFiles: Bool = false, + classifier: ChatArtifactGalleryClassifier = ChatArtifactGalleryClassifier() + ) { + groups = [ + ChatArtifactGalleryGroup( + kind: .created, + items: Self.project( + snapshot.created, + filter: filter, + sort: sort, + includesMissingFiles: includesMissingFiles, + classifier: classifier + ) + ), + ChatArtifactGalleryGroup( + kind: .attached, + items: Self.project( + snapshot.attached, + filter: filter, + sort: sort, + includesMissingFiles: includesMissingFiles, + classifier: classifier + ) + ), + ChatArtifactGalleryGroup( + kind: .referenced, + items: Self.project( + snapshot.referenced, + filter: filter, + sort: sort, + includesMissingFiles: includesMissingFiles, + classifier: classifier + ) + ), + ] + } + + /// Returns the visible rows for one fixed group. + /// + /// - Parameter kind: Group whose projected rows are needed. + /// - Returns: Visible rows, or an empty array when the group is absent. + public func items(in kind: ChatArtifactGalleryGroupKind) -> [ChatArtifactGalleryItem] { + groups.first { $0.kind == kind }?.items ?? [] + } + + private static func project( + _ items: [ChatArtifactGalleryItem], + filter: ChatArtifactGalleryFilter, + sort: ChatArtifactGallerySort, + includesMissingFiles: Bool, + classifier: ChatArtifactGalleryClassifier + ) -> [ChatArtifactGalleryItem] { + let visible = includesMissingFiles ? items : items.filter(\.exists) + let filtered = filter == .all + ? visible + : visible.filter { classifier.filter(for: $0) == filter } + switch sort { + case .recent: + return filtered + case .name: + return filtered.enumerated().sorted { lhs, rhs in + let comparison = lhs.element.displayName.localizedCaseInsensitiveCompare( + rhs.element.displayName + ) + return comparison == .orderedSame + ? lhs.offset < rhs.offset + : comparison == .orderedAscending + }.map(\.element) + case .size: + return filtered.enumerated().sorted { lhs, rhs in + switch (lhs.element.size, rhs.element.size) { + case let (left?, right?) where left != right: + return left > right + case (_?, nil): + return true + case (nil, _?): + return false + default: + return lhs.offset < rhs.offset + } + }.map(\.element) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift new file mode 100644 index 000000000000..bd710dece1ae --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySnapshot.swift @@ -0,0 +1,180 @@ +/// Accumulated section rows and paging state for a session artifact gallery. +public struct ChatArtifactGallerySnapshot: Sendable, Equatable { + /// Created artifact rows accumulated across pages. + public let created: [ChatArtifactGalleryItem] + /// Complete created-row count reported by the host. + public let createdTotal: Int + /// Attached artifact rows accumulated across pages. + public let attached: [ChatArtifactGalleryItem] + /// Complete attached-row count reported by the host. + public let attachedTotal: Int + /// Referenced artifact rows accumulated across pages. + public let referenced: [ChatArtifactGalleryItem] + /// Complete referenced-row count reported by the host. + public let referencedTotal: Int + /// Cursor for the next page across any incomplete section. + public let nextCursor: String? + /// Host snapshot generation that served the latest page. + public let generation: String + + /// Whether the complete gallery has no rows. + public var isEmpty: Bool { + createdTotal == 0 && attachedTotal == 0 && referencedTotal == 0 + } + + /// Creates an accumulated snapshot from a first gallery page. + /// + /// - Parameter page: The first sectioned gallery page. + public init(page: ChatArtifactGalleryPage) { + created = page.created + createdTotal = page.createdTotal + attached = page.attached + attachedTotal = page.attachedTotal + referenced = page.referenced + referencedTotal = page.referencedTotal + nextCursor = page.nextCursor + generation = page.generation + } + + /// Appends one sectioned page while dropping paths already in any section. + /// + /// First-seen order is preserved both across pages and within `page`. + /// + /// - Parameter page: The next sectioned gallery page. + /// - Returns: A snapshot containing only path-unique appended rows. + public func appending(_ page: ChatArtifactGalleryPage) -> ChatArtifactGallerySnapshot { + guard !page.requiresPagingRestart else { return self } + var seenPaths = Set((created + attached + referenced).map(\.path)) + let uniqueCreated = page.created.filter { item in + seenPaths.insert(item.path).inserted + } + let uniqueAttached = page.attached.filter { item in + seenPaths.insert(item.path).inserted + } + let uniqueReferenced = page.referenced.filter { item in + seenPaths.insert(item.path).inserted + } + return ChatArtifactGallerySnapshot( + created: created + uniqueCreated, + createdTotal: page.createdTotal, + attached: attached + uniqueAttached, + attachedTotal: page.attachedTotal, + referenced: referenced + uniqueReferenced, + referencedTotal: page.referencedTotal, + nextCursor: page.nextCursor, + generation: page.generation + ) + } + + /// Rebinds paging to a fresh first page without moving already visible rows. + /// + /// Existing rows retain their exact section order. Newly discovered first-page + /// rows are appended, while the fresh generation and cursor become authoritative. + /// This is used for background stale-cursor recovery where leading insertions + /// could otherwise move the reader's scroll position. + /// + /// - Parameter fresh: First page from the current host generation. + /// - Returns: A stable-order snapshot ready to continue fresh paging. + public func restartingPaging( + withFreshFirstPage fresh: ChatArtifactGallerySnapshot + ) -> ChatArtifactGallerySnapshot { + var seenPaths = Set((created + attached + referenced).map(\.path)) + let newCreated = fresh.created.filter { seenPaths.insert($0.path).inserted } + let newAttached = fresh.attached.filter { seenPaths.insert($0.path).inserted } + let newReferenced = fresh.referenced.filter { seenPaths.insert($0.path).inserted } + return ChatArtifactGallerySnapshot( + created: created + newCreated, + createdTotal: max(fresh.createdTotal, created.count + newCreated.count), + attached: attached + newAttached, + attachedTotal: max(fresh.attachedTotal, attached.count + newAttached.count), + referenced: referenced + newReferenced, + referencedTotal: max(fresh.referencedTotal, referenced.count + newReferenced.count), + nextCursor: fresh.nextCursor, + generation: fresh.generation + ) + } + + /// Adopts a fresh generation cursor without changing any visible row array. + /// + /// This supports a deferred live-refresh pill: paging can stop using the + /// rejected cursor immediately while every rendered row remains fixed. + /// + /// - Parameter fresh: First page from the current host generation. + /// - Returns: The unchanged rows with fresh totals and paging identity. + public func rebasingPaging( + ontoFreshFirstPage fresh: ChatArtifactGallerySnapshot + ) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot( + created: created, + createdTotal: max(fresh.createdTotal, created.count), + attached: attached, + attachedTotal: max(fresh.attachedTotal, attached.count), + referenced: referenced, + referencedTotal: max(fresh.referencedTotal, referenced.count), + nextCursor: fresh.nextCursor, + generation: fresh.generation + ) + } + + /// Reconciles a newer first page without discarding rows already loaded. + /// + /// Fresh rows lead their provenance sections, while previously loaded rows + /// retain their relative order. A path that moved between provenance + /// sections appears only in the fresh section. + /// + /// - Parameter fresh: First-page snapshot from a newer host generation. + /// - Returns: A generation-updated snapshot that preserves loaded history. + public func reconciling(withFreshFirstPage fresh: ChatArtifactGallerySnapshot) -> ChatArtifactGallerySnapshot { + var seenPaths: Set = [] + let freshCreated = fresh.created.filter { seenPaths.insert($0.path).inserted } + let freshAttached = fresh.attached.filter { seenPaths.insert($0.path).inserted } + let freshReferenced = fresh.referenced.filter { seenPaths.insert($0.path).inserted } + let retainedCreated = created.filter { seenPaths.insert($0.path).inserted } + let retainedAttached = attached.filter { seenPaths.insert($0.path).inserted } + let retainedReferenced = referenced.filter { seenPaths.insert($0.path).inserted } + let mergedReferenced = freshReferenced + retainedReferenced + return ChatArtifactGallerySnapshot( + created: freshCreated + retainedCreated, + createdTotal: max(fresh.createdTotal, freshCreated.count + retainedCreated.count), + attached: freshAttached + retainedAttached, + attachedTotal: max(fresh.attachedTotal, freshAttached.count + retainedAttached.count), + referenced: mergedReferenced, + referencedTotal: max(fresh.referencedTotal, mergedReferenced.count), + nextCursor: fresh.nextCursor, + generation: fresh.generation + ) + } + + func limitingReferenced(to maximumCount: Int) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot( + created: created, + createdTotal: createdTotal, + attached: attached, + attachedTotal: attachedTotal, + referenced: Array(referenced.prefix(max(0, maximumCount))), + referencedTotal: referencedTotal, + nextCursor: nextCursor, + generation: generation + ) + } + + private init( + created: [ChatArtifactGalleryItem], + createdTotal: Int, + attached: [ChatArtifactGalleryItem], + attachedTotal: Int, + referenced: [ChatArtifactGalleryItem], + referencedTotal: Int, + nextCursor: String?, + generation: String + ) { + self.created = created + self.createdTotal = createdTotal + self.attached = attached + self.attachedTotal = attachedTotal + self.referenced = referenced + self.referencedTotal = referencedTotal + self.nextCursor = nextCursor + self.generation = generation + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySort.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySort.swift new file mode 100644 index 000000000000..f01bd4563756 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySort.swift @@ -0,0 +1,9 @@ +/// A sheet-wide ordering applied independently within every artifact group. +public enum ChatArtifactGallerySort: String, CaseIterable, Sendable, Equatable { + /// Preserves the host's descending last-reference sequence order. + case recent + /// Orders names ascending, case-insensitively. + case name + /// Orders known sizes largest-first, followed by unknown sizes. + case size +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySwipeOrder.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySwipeOrder.swift new file mode 100644 index 000000000000..b1e6e4f43f6c --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactGallerySwipeOrder.swift @@ -0,0 +1,86 @@ +/// The flattened visible-file order used by gallery viewer paging. +public struct ChatArtifactGallerySwipeOrder: Sendable, Equatable { + /// Visible non-folder files in paging order. + public let files: [ChatArtifactGalleryItem] + + /// Visible non-folder paths in paging order. + public var paths: [String] { files.map(\.path) } + + /// Number of visible files in the paging order. + public var count: Int { files.count } + + /// Creates a paging order by flattening visible groups in display order. + /// + /// Directory rows are excluded, and a repeated path keeps its first visible + /// position. + /// + /// - Parameter groups: Filtered and sorted groups in display order. + public init(groups: [ChatArtifactGalleryGroup]) { + self.init(items: groups.flatMap(\.items)) + } + + /// Creates a paging order from one flat visible collection. + /// + /// - Parameter items: Filtered and sorted items in display order. + public init(items: [ChatArtifactGalleryItem]) { + var seenPaths: Set = [] + files = items.filter { item in + item.kind != .directory && seenPaths.insert(item.path).inserted + } + } + + /// Creates a paging order from terminal-scan references. + /// + /// - Parameter references: Visible terminal references in display order. + public init(references: [TerminalArtifactReference]) { + self.init(items: references.map { reference in + ChatArtifactGalleryItem( + path: reference.path, + kind: reference.kind, + displayName: reference.displayName, + size: reference.size, + modifiedAt: reference.modifiedAt + ) + }) + } + + /// Returns the bounded previous/current/next window around one file. + /// + /// - Parameter path: Current visible file path. + /// - Returns: At most three files, or an empty array for an unknown path. + public func pageWindow(around path: String) -> [ChatArtifactGalleryItem] { + guard let currentIndex = files.firstIndex(where: { $0.path == path }) else { + return [] + } + let lowerBound = currentIndex > files.startIndex + ? files.index(before: currentIndex) + : currentIndex + let upperBound = files.index( + after: currentIndex < files.index(before: files.endIndex) + ? files.index(after: currentIndex) + : currentIndex + ) + return Array(files[lowerBound.. String? { + guard let index = files.firstIndex(where: { $0.path == path }), + index > files.startIndex else { return nil } + return files[files.index(before: index)].path + } + + /// Returns the file after a visible path. + /// + /// - Parameter path: Current visible file path. + /// - Returns: Next path, or `nil` at the last file or for an unknown path. + public func nextPath(after path: String) -> String? { + guard let index = files.firstIndex(where: { $0.path == path }) else { return nil } + let nextIndex = files.index(after: index) + guard nextIndex < files.endIndex else { return nil } + return files[nextIndex].path + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift index 67b060c9b189..2ff84eee34ff 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactIndexedReference.swift @@ -2,7 +2,7 @@ import Foundation /// A transcript-derived path with de-duplicated provenance and its last position. public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identifiable { - /// Transcript path exactly as parsed. + /// Canonical display path when the file exists, otherwise its lexical path. public let path: String /// Highest-precedence provenance observed for the path. public let provenance: ChatArtifactProvenance @@ -19,25 +19,28 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi self.lastReferencedSeq = lastReferencedSeq } - /// Derives one record per normalized path from parsed transcript messages. + /// Derives one record per canonical path identity from parsed transcript messages. /// /// Agent edits outrank attachments, which outrank read-only references; /// every occurrence still advances the path's last-reference sequence. - /// Relative paths are lexically resolved against the session working - /// directory without accessing the filesystem. + /// Existing absolute paths resolve filesystem aliases after lexical + /// normalization. Missing paths stay lexical so deleted artifacts remain. /// /// - Parameters: /// - messages: Parsed transcript messages to inspect. /// - supplementalReferences: Raw pre-budget and artifacts-only parser /// occurrences that are absent from the visible message stream. /// - workingDirectory: Absolute session directory used for relative paths. - /// - Returns: De-duplicated artifact references with normalized paths. + /// - canonicalizer: Filesystem identity operation used after lexical normalization. + /// - Returns: De-duplicated artifact references with canonical display paths. public static func derive( from messages: [ChatMessage], supplementalReferences: [ChatArtifactTranscriptReference] = [], - workingDirectory: String? = nil + workingDirectory: String? = nil, + canonicalizer: ChatArtifactPathCanonicalizer = ChatArtifactPathCanonicalizer() ) -> [ChatArtifactIndexedReference] { var byPath: [String: ChatArtifactIndexedReference] = [:] + var canonicalPathByLexicalPath: [String: String] = [:] let detector = TerminalArtifactPathDetector() let normalizer = ChatArtifactPathNormalizer(workingDirectory: workingDirectory) for message in messages { @@ -72,12 +75,26 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi guard let path = normalizer.structuredPath(rawPath) else { continue } - Self.merge(path: path, provenance: provenance, seq: message.seq, into: &byPath) + Self.merge( + path: path, + provenance: provenance, + seq: message.seq, + canonicalizer: canonicalizer, + canonicalPathByLexicalPath: &canonicalPathByLexicalPath, + into: &byPath + ) } for rawPath in textOccurrences where ChatArtifactPathNormalizer.isAbsoluteFreeTextCandidate(rawPath) { guard let path = normalizer.freeTextPath(rawPath) else { continue } - Self.merge(path: path, provenance: .referenced, seq: message.seq, into: &byPath) + Self.merge( + path: path, + provenance: .referenced, + seq: message.seq, + canonicalizer: canonicalizer, + canonicalPathByLexicalPath: &canonicalPathByLexicalPath, + into: &byPath + ) } } for reference in supplementalReferences { @@ -92,6 +109,8 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi path: path, provenance: reference.provenance, seq: reference.seq, + canonicalizer: canonicalizer, + canonicalPathByLexicalPath: &canonicalPathByLexicalPath, into: &byPath ) } @@ -102,11 +121,20 @@ public struct ChatArtifactIndexedReference: Sendable, Equatable, Codable, Identi path: String, provenance: ChatArtifactProvenance, seq: Int, + canonicalizer: ChatArtifactPathCanonicalizer, + canonicalPathByLexicalPath: inout [String: String], into byPath: inout [String: ChatArtifactIndexedReference] ) { - let previous = byPath[path] - byPath[path] = ChatArtifactIndexedReference( - path: path, + let canonicalPath: String + if let cached = canonicalPathByLexicalPath[path] { + canonicalPath = cached + } else { + canonicalPath = canonicalizer.canonicalPathKey(for: path) + canonicalPathByLexicalPath[path] = canonicalPath + } + let previous = byPath[canonicalPath] + byPath[canonicalPath] = ChatArtifactIndexedReference( + path: canonicalPath, provenance: Self.higherPrecedence(previous?.provenance, provenance), lastReferencedSeq: max(previous?.lastReferencedSeq ?? Int.min, seq) ) diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactLRUCache.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactLRUCache.swift new file mode 100644 index 000000000000..e45e6a2a397f --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactLRUCache.swift @@ -0,0 +1,50 @@ +/// A small value-type least-recently-used cache for artifact infrastructure. +public struct ChatArtifactLRUCache: Sendable { + private struct Entry: Sendable { + let value: Value + let access: UInt64 + } + + private let capacity: Int + private var entries: [Key: Entry] = [:] + private var accessCounter: UInt64 = 0 + + /// Number of currently retained entries. + public var count: Int { entries.count } + + /// Creates a bounded cache. + /// + /// - Parameter capacity: Maximum retained entry count. + public init(capacity: Int) { + self.capacity = max(0, capacity) + } + + /// Returns and marks a value as most recently used. + /// + /// - Parameter key: Cache identity to look up. + /// - Returns: The retained value, or `nil`. + public mutating func value(forKey key: Key) -> Value? { + guard let entry = entries[key] else { return nil } + accessCounter &+= 1 + entries[key] = Entry(value: entry.value, access: accessCounter) + return entry.value + } + + /// Inserts or replaces a value and evicts the least recently used entry. + /// + /// - Parameters: + /// - value: Value to retain. + /// - key: Cache identity for the value. + public mutating func insert(_ value: Value, forKey key: Key) { + guard capacity > 0 else { + entries.removeAll(keepingCapacity: false) + return + } + accessCounter &+= 1 + entries[key] = Entry(value: value, access: accessCounter) + while entries.count > capacity, + let leastRecent = entries.min(by: { $0.value.access < $1.value.access })?.key { + entries.removeValue(forKey: leastRecent) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactPathCanonicalizer.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactPathCanonicalizer.swift new file mode 100644 index 000000000000..30077014902b --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactPathCanonicalizer.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Produces stable filesystem identity keys for lexically normalized artifact paths. +public struct ChatArtifactPathCanonicalizer: Sendable { + private let implementation: @Sendable (String) -> String + + /// Creates a canonicalizer backed by the current filesystem. + public init() { + implementation = Self.foundationCanonicalPathKey + } + + /// Creates a canonicalizer with an injected identity operation. + /// + /// - Parameter canonicalPathKey: An operation that maps one lexically + /// normalized path to its canonical identity key. + public init(canonicalPathKey: @escaping @Sendable (String) -> String) { + implementation = canonicalPathKey + } + + /// Returns the canonical identity key for a lexically normalized path. + /// + /// Existing absolute paths resolve symlinks and use their on-disk case. + /// Missing and relative paths remain unchanged so deleted or unresolved + /// transcript artifacts stay visible. + /// + /// - Parameter path: An already-lexically-normalized artifact path. + /// - Returns: The filesystem identity key, or `path` when it is absent. + public func canonicalPathKey(for path: String) -> String { + implementation(path) + } + + private static func foundationCanonicalPathKey(_ path: String) -> String { + guard path.hasPrefix("/") else { return path } + let sourceURL = URL(fileURLWithPath: path) + guard (try? sourceURL.checkResourceIsReachable()) == true else { + return path + } + let resolvedURL = sourceURL.resolvingSymlinksInPath().standardizedFileURL + if let values = try? resolvedURL.resourceValues(forKeys: [.canonicalPathKey]), + let canonicalPath = values.canonicalPath, + !canonicalPath.isEmpty { + return canonicalPath + } + return resolvedURL.path + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift index ff7ad0f0f718..fbfccdcd8abe 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactScope.swift @@ -5,10 +5,18 @@ import Foundation /// The checker canonicalizes transcript-referenced paths and requested paths /// through an injected resolver, then answers whether a request may stat, /// fetch, thumbnail, or list a path without touching the requested path's -/// filesystem metadata. File operations are allowed for exact referenced -/// paths and for one immediate child of a referenced directory. Directory -/// listing is allowed only for directories that were themselves referenced. +/// filesystem metadata. Exact referenced paths are always allowed. Descendants +/// of referenced directories follow the configured ``DirectoryAccessMode``. public struct ChatArtifactScope: Sendable { + /// How referenced directories authorize descendant paths. + public enum DirectoryAccessMode: String, Sendable { + /// Authorize every canonical descendant of a referenced directory. + case subtree + /// Authorize immediate children for file operations and only the + /// referenced directory itself for listing, matching the legacy rule. + case oneLevel + } + /// Filesystem operations needed to canonicalize and classify referenced paths. public protocol FileSystemResolving: Sendable { /// Resolves symlinks in a path and returns a filesystem path string. @@ -53,21 +61,25 @@ public struct ChatArtifactScope: Sendable { private static let maximumPathLength = 4096 - private let referencedPaths: Set private let referencedCanonicalPaths: Set private let referencedDirectoryCanonicalPaths: Set + private let directoryAccessMode: DirectoryAccessMode private let resolver: any FileSystemResolving /// Creates a scope checker from transcript-referenced path strings. /// /// - Parameters: /// - referencedPaths: Paths as they appeared in the transcript. + /// - directoryAccessMode: Descendant authorization policy. The default + /// preserves the legacy one-level behavior for existing callers. /// - resolver: Filesystem resolver used for canonicalization. public init( referencedPaths: Set, + directoryAccessMode: DirectoryAccessMode = .oneLevel, resolver: any FileSystemResolving ) { self.resolver = resolver + self.directoryAccessMode = directoryAccessMode let canonical = referencedPaths.compactMap { path -> ReferencedPath? in guard let canonicalPath = Self.canonicalPath(path, resolver: resolver) else { return nil @@ -77,7 +89,6 @@ public struct ChatArtifactScope: Sendable { isDirectory: resolver.isDirectory(canonicalPath) ) } - self.referencedPaths = Set(canonical) self.referencedCanonicalPaths = Set(canonical.map(\.canonicalPath)) self.referencedDirectoryCanonicalPaths = Set( canonical.compactMap { $0.isDirectory == true ? $0.canonicalPath : nil } @@ -95,10 +106,18 @@ public struct ChatArtifactScope: Sendable { if referencedCanonicalPaths.contains(canonicalPath) { return canonicalPath } - guard let parent = Self.parentPath(ofCanonicalPath: canonicalPath), - referencedDirectoryCanonicalPaths.contains(parent) - else { - return nil + switch directoryAccessMode { + case .subtree: + guard referencedDirectoryCanonicalPaths.contains(where: { + Self.isCanonicalPath(canonicalPath, containedIn: $0) + }) else { + return nil + } + case .oneLevel: + guard let parent = Self.parentPath(ofCanonicalPath: canonicalPath), + referencedDirectoryCanonicalPaths.contains(parent) else { + return nil + } } return canonicalPath } @@ -120,12 +139,19 @@ public struct ChatArtifactScope: Sendable { /// Resolves an allowed directory-list request to its canonical path. /// /// - Parameter path: Requested absolute directory path. - /// - Returns: Canonical path when the directory itself was referenced, - /// otherwise `nil`. + /// - Returns: Canonical path when the directory itself was referenced, or + /// when subtree access permits the canonical descendant; otherwise `nil`. public func canonicalDirectoryListPath(for path: String) -> String? { - guard let canonicalPath = Self.canonicalPath(path, resolver: resolver), - referencedPaths.contains(ReferencedPath(canonicalPath: canonicalPath, isDirectory: true)) - else { + guard let canonicalPath = Self.canonicalPath(path, resolver: resolver) else { + return nil + } + if referencedCanonicalPaths.contains(canonicalPath) { + return canonicalPath + } + guard directoryAccessMode == .subtree, + referencedDirectoryCanonicalPaths.contains(where: { + Self.isCanonicalPath(canonicalPath, containedIn: $0) + }) else { return nil } return canonicalPath @@ -162,4 +188,11 @@ public struct ChatArtifactScope: Sendable { } return parent } + + /// Tests containment only after both inputs have been canonicalized. + private static func isCanonicalPath(_ path: String, containedIn directory: String) -> Bool { + guard path != directory else { return true } + let prefix = directory == "/" ? "/" : "\(directory)/" + return path.hasPrefix(prefix) + } } diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift index 97240b163ed6..95e727a26f52 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/ChatArtifactTransferPolicy.swift @@ -9,6 +9,8 @@ public struct ChatArtifactTransferPolicy: Sendable, Equatable { public let mobileSyncFrameLimitBytes: Int /// Maximum file size the iOS viewer previews inline. public let maxPreviewBytes: Int64 + /// Maximum movie or audio size streamed to an iOS temporary file. + public let maxMediaPreviewBytes: Int64 /// Creates an artifact transfer policy. /// @@ -16,14 +18,17 @@ public struct ChatArtifactTransferPolicy: Sendable, Equatable { /// - maxRawChunkBytes: Maximum raw bytes returned by one fetch chunk. /// - mobileSyncFrameLimitBytes: Maximum mobile-sync frame size. /// - maxPreviewBytes: Maximum inline preview file size. + /// - maxMediaPreviewBytes: Maximum temporary-file media preview size. public init( maxRawChunkBytes: Int = 3 * 1024 * 1024, mobileSyncFrameLimitBytes: Int = 8 * 1024 * 1024, - maxPreviewBytes: Int64 = 64 * 1024 * 1024 + maxPreviewBytes: Int64 = 64 * 1024 * 1024, + maxMediaPreviewBytes: Int64 = 512 * 1024 * 1024 ) { self.maxRawChunkBytes = maxRawChunkBytes self.mobileSyncFrameLimitBytes = mobileSyncFrameLimitBytes self.maxPreviewBytes = maxPreviewBytes + self.maxMediaPreviewBytes = maxMediaPreviewBytes } /// Clamps a requested chunk length to the policy's raw-byte maximum. diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactAuthorizationStore.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactAuthorizationStore.swift new file mode 100644 index 000000000000..f07d764260ab --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactAuthorizationStore.swift @@ -0,0 +1,112 @@ +import Foundation + +/// Retains bounded terminal-scan generations for subsequent artifact reads. +public actor TerminalArtifactAuthorizationStore { + private typealias Generation = ( + ordinal: UInt64, + canonicalPaths: Set, + expiresAt: Date + ) + + private let timeToLive: TimeInterval + private let maximumGenerationsPerSurface: Int + private let maximumSurfaceCount: Int + private var generationsBySurfaceKey: [String: [Generation]] = [:] + private var nextOrdinalBySurfaceKey: [String: UInt64] = [:] + private var lastAccessBySurfaceKey: [String: Date] = [:] + + /// Creates a bounded authorization store. + /// + /// - Parameters: + /// - timeToLive: Lifetime of each scan generation in seconds. + /// - maximumGenerationsPerSurface: Maximum concurrent listings retained for one surface. + /// - maximumSurfaceCount: Maximum terminal surfaces retained at once. + public init( + timeToLive: TimeInterval = 10 * 60, + maximumGenerationsPerSurface: Int = 4, + maximumSurfaceCount: Int = 64 + ) { + self.timeToLive = max(1, timeToLive) + self.maximumGenerationsPerSurface = max(1, maximumGenerationsPerSurface) + self.maximumSurfaceCount = max(1, maximumSurfaceCount) + } + + /// Records one canonical terminal-scan generation. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the scanned terminal. + /// - surfaceID: Scanned terminal surface. + /// - canonicalPaths: Canonical paths returned to the listing client. + /// - date: Scan completion time. + public func record( + workspaceID: String, + surfaceID: String, + canonicalPaths: Set, + at date: Date = Date() + ) { + purgeExpired(at: date) + let key = surfaceKey(workspaceID: workspaceID, surfaceID: surfaceID) + let ordinal = (nextOrdinalBySurfaceKey[key] ?? 0) &+ 1 + nextOrdinalBySurfaceKey[key] = ordinal + var generations = generationsBySurfaceKey[key] ?? [] + generations.append(( + ordinal: ordinal, + canonicalPaths: canonicalPaths, + expiresAt: date.addingTimeInterval(timeToLive) + )) + if generations.count > maximumGenerationsPerSurface { + generations.removeFirst(generations.count - maximumGenerationsPerSurface) + } + generationsBySurfaceKey[key] = generations + lastAccessBySurfaceKey[key] = date + evictLeastRecentSurfacesIfNeeded() + } + + /// Returns the union of unexpired scan generations for one terminal. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the terminal. + /// - surfaceID: Terminal surface being read. + /// - date: Read time used for deterministic expiry. + /// - Returns: Canonical paths listed by retained scan generations. + public func authorizedPaths( + workspaceID: String, + surfaceID: String, + at date: Date = Date() + ) -> Set { + purgeExpired(at: date) + let key = surfaceKey(workspaceID: workspaceID, surfaceID: surfaceID) + guard let generations = generationsBySurfaceKey[key] else { return [] } + lastAccessBySurfaceKey[key] = date + return generations.reduce(into: Set()) { result, generation in + result.formUnion(generation.canonicalPaths) + } + } + + private func surfaceKey(workspaceID: String, surfaceID: String) -> String { + "\(workspaceID)\u{0}\(surfaceID)" + } + + private func purgeExpired(at date: Date) { + for key in Array(generationsBySurfaceKey.keys) { + let retained = generationsBySurfaceKey[key, default: []] + .filter { $0.expiresAt > date } + if retained.isEmpty { + generationsBySurfaceKey.removeValue(forKey: key) + nextOrdinalBySurfaceKey.removeValue(forKey: key) + lastAccessBySurfaceKey.removeValue(forKey: key) + } else { + generationsBySurfaceKey[key] = retained + } + } + } + + private func evictLeastRecentSurfacesIfNeeded() { + while generationsBySurfaceKey.count > maximumSurfaceCount, + let oldestKey = lastAccessBySurfaceKey.min(by: { $0.value < $1.value })?.key { + generationsBySurfaceKey.removeValue(forKey: oldestKey) + nextOrdinalBySurfaceKey.removeValue(forKey: oldestKey) + lastAccessBySurfaceKey.removeValue(forKey: oldestKey) + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift index 5f9725abfcde..e33694715e12 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactScope.swift @@ -1,11 +1,13 @@ import Foundation -/// Exact artifact scope for paths currently present in terminal text. +/// Artifact scope for paths currently present in terminal text and authorized descendants. public struct TerminalArtifactScope: Sendable { private let terminalText: String private let workingDirectory: String? private let resolver: any ChatArtifactScope.FileSystemResolving private let detector: TerminalArtifactPathDetector + private let canonicalizer: ChatArtifactPathCanonicalizer + private let directoryAccessMode: ChatArtifactScope.DirectoryAccessMode /// Creates a terminal artifact scope checker. /// @@ -14,16 +16,23 @@ public struct TerminalArtifactScope: Sendable { /// - workingDirectory: Terminal cwd used to resolve relative path tokens. /// - resolver: Filesystem resolver used for existence and canonicalization. /// - detector: Path-token detector. + /// - canonicalizer: Filesystem identity operation used to de-duplicate rows. + /// - directoryAccessMode: Descendant authorization policy. The default + /// preserves exact/one-level legacy behavior for existing callers. public init( terminalText: String, workingDirectory: String?, resolver: any ChatArtifactScope.FileSystemResolving, - detector: TerminalArtifactPathDetector = TerminalArtifactPathDetector() + detector: TerminalArtifactPathDetector = TerminalArtifactPathDetector(), + canonicalizer: ChatArtifactPathCanonicalizer = ChatArtifactPathCanonicalizer(), + directoryAccessMode: ChatArtifactScope.DirectoryAccessMode = .oneLevel ) { self.terminalText = terminalText self.workingDirectory = workingDirectory self.resolver = resolver self.detector = detector + self.canonicalizer = canonicalizer + self.directoryAccessMode = directoryAccessMode } /// Current terminal artifact paths, canonicalized, existing, deduped, and capped. @@ -36,40 +45,60 @@ public struct TerminalArtifactScope: Sendable { var result: [String] = [] for candidate in candidates { guard resolver.isDirectory(candidate) != nil, - let canonical = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver), - !seen.contains(canonical) else { + let resolved = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver) else { + continue + } + let canonical = canonicalizer.canonicalPathKey(for: resolved) + guard seen.insert(canonical).inserted else { continue } - seen.insert(canonical) result.append(canonical) if result.count >= limit { break } } return result } - /// Resolves a request when its exact canonical path appears in terminal text. + /// Resolves a file request against canonical visible paths and directory policy. /// /// - Parameter path: Requested path, absolute or relative to the terminal cwd. /// - Returns: Canonical path when authorized, otherwise `nil`. public func canonicalPath(for path: String) -> String? { - guard let absoluteRequest = absolutePath(for: path) else { + guard let absoluteRequest = absolutePath(for: path), + let canonicalRequest = canonicalIdentity(for: absoluteRequest) else { return nil } - guard let canonicalRequest = ChatArtifactScope.canonicalizedPath(absoluteRequest, resolver: resolver) else { + return artifactScope().canonicalFilePath(for: canonicalRequest) + } + + /// Resolves a directory-list request against canonical visible paths and policy. + /// + /// - Parameter path: Requested path, absolute or relative to the terminal cwd. + /// - Returns: Canonical directory path when authorized, otherwise `nil`. + public func canonicalDirectoryListPath(for path: String) -> String? { + guard let absoluteRequest = absolutePath(for: path), + let canonicalRequest = canonicalIdentity(for: absoluteRequest) else { return nil } - var seen: Set = [] - for candidate in detector.paths(in: terminalText).compactMap(absolutePath(for:)) { - guard let canonical = ChatArtifactScope.canonicalizedPath(candidate, resolver: resolver), - !seen.contains(canonical) else { - continue - } - seen.insert(canonical) - if canonical == canonicalRequest { - return canonicalRequest - } + return artifactScope().canonicalDirectoryListPath(for: canonicalRequest) + } + + private func artifactScope() -> ChatArtifactScope { + ChatArtifactScope( + referencedPaths: Set( + detector.paths(in: terminalText) + .compactMap(absolutePath(for:)) + .compactMap(canonicalIdentity(for:)) + ), + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) + } + + private func canonicalIdentity(for path: String) -> String? { + guard let resolved = ChatArtifactScope.canonicalizedPath(path, resolver: resolver) else { + return nil } - return nil + return canonicalizer.canonicalPathKey(for: resolved) } private func absolutePath(for token: String) -> String? { diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift index 9c8bbe837858..76393a15d7b5 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/TerminalArtifactTapHitTester.swift @@ -45,7 +45,7 @@ public struct TerminalArtifactTapHitTester: Sendable { var currentRawEndColumn = token.rawEndColumn while currentRawEndColumn >= columns, - lines[currentRow].count >= columns, + Self.cellWidth(of: lines[currentRow]) >= columns, currentRow + 1 < lines.count, let continuation = leadingContinuation(in: lines[currentRow + 1]) { currentRow += 1 @@ -58,7 +58,7 @@ public struct TerminalArtifactTapHitTester: Sendable { )) } - let normalizedPath = TerminalArtifactPathDetector().tokens(in: rawPath).first?.path ?? token.path + let normalizedPath = Self.normalizedPath(in: rawPath) ?? token.path return StitchedPath(path: normalizedPath, segments: segments) } @@ -76,22 +76,53 @@ public struct TerminalArtifactTapHitTester: Sendable { index = line.index(after: index) } let raw = String(line[tokenStart.. String? { + if let path = TerminalArtifactPathDetector().tokens(in: raw).first?.path { + return path + } + + var candidate = raw.trimmingCharacters(in: bareFilenameLeadingCharacters) + while let scalar = candidate.unicodeScalars.last, + bareFilenameTrailingCharacters.contains(scalar) { + candidate.removeLast() + } + guard !candidate.isEmpty, + !candidate.contains("/"), + !candidate.contains("@"), + !candidate.contains("://"), + !candidate.unicodeScalars.contains(where: forbiddenBareFilenameCharacters.contains) + else { return nil } + + let path = candidate as NSString + let pathExtension = path.pathExtension + let basename = path.deletingPathExtension + guard !basename.isEmpty, + !pathExtension.isEmpty, + candidate.contains(where: { $0.isLetter }) + else { return nil } + return candidate + } + private func leadingContinuation(in line: String) -> Continuation? { guard let first = line.first, !first.isWhitespace, @@ -100,7 +131,49 @@ public struct TerminalArtifactTapHitTester: Sendable { } let text = String(line.prefix(while: { !$0.isWhitespace })) guard !text.isEmpty else { return nil } - return Continuation(text: text, endColumn: text.count) + return Continuation(text: text, endColumn: Self.cellWidth(of: text)) + } + + private static func cellWidth(of text: String) -> Int { + text.reduce(0) { $0 + cellWidth(of: $1) } + } + + private static func cellWidth(of character: Character) -> Int { + let scalars = character.unicodeScalars + guard scalars.contains(where: { !isZeroWidth($0) }) else { return 0 } + if scalars.contains(where: { isWide($0) || $0.properties.isEmojiPresentation }) { + return 2 + } + return 1 + } + + private static func isZeroWidth(_ scalar: UnicodeScalar) -> Bool { + switch scalar.properties.generalCategory { + case .nonspacingMark, .spacingMark, .enclosingMark, .control, .format: + return true + default: + return false + } + } + + private static func isWide(_ scalar: UnicodeScalar) -> Bool { + switch scalar.value { + case 0x1100...0x115F, + 0x2329...0x232A, + 0x2E80...0xA4CF, + 0xAC00...0xD7A3, + 0xF900...0xFAFF, + 0xFE10...0xFE19, + 0xFE30...0xFE6F, + 0xFF00...0xFF60, + 0xFFE0...0xFFE6, + 0x16FE0...0x18DFF, + 0x1AFF0...0x1B2FF, + 0x20000...0x3FFFD: + return true + default: + return false + } } private static func isPathContinuation(_ character: Character) -> Bool { @@ -142,6 +215,9 @@ public struct TerminalArtifactTapHitTester: Sendable { } private static let leadingTrimCharacters: Set = ["\"", "'", "`", "(", "[", "{", "<"] + private static let bareFilenameLeadingCharacters = CharacterSet(charactersIn: "\"'`([{<") + private static let bareFilenameTrailingCharacters = CharacterSet(charactersIn: "\"'`)]}>,;:!?.") + private static let forbiddenBareFilenameCharacters = CharacterSet(charactersIn: "<>\"'\\`") private static let pathContinuationCharacters: Set = [ "/", ".", "_", "-", "+", "=", "~", "@", "%", ":", "\\", ] diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift new file mode 100644 index 000000000000..f48d5365e3ed --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Session/ChatSessionSurfaceIndex.swift @@ -0,0 +1,82 @@ +/// A mutation-maintained index from terminal surfaces to chat session IDs. +public struct ChatSessionSurfaceIndex: Sendable { + private var sessionIDsBySurfaceID: [String: Set] = [:] + + /// Creates an empty surface index. + public init() {} + + /// Reconciles one session's previous and current surface bindings. + /// + /// - Parameters: + /// - sessionID: Stable identity of the changed session. + /// - previousSurfaceID: Surface before the mutation, or `nil`. + /// - currentSurfaceID: Surface after the mutation, or `nil`. + public mutating func update( + sessionID: SessionID, + previousSurfaceID: String?, + currentSurfaceID: String? + ) { + if let previousSurfaceID, previousSurfaceID != currentSurfaceID { + sessionIDsBySurfaceID[previousSurfaceID]?.remove(sessionID) + if sessionIDsBySurfaceID[previousSurfaceID]?.isEmpty == true { + sessionIDsBySurfaceID.removeValue(forKey: previousSurfaceID) + } + } + if let currentSurfaceID { + sessionIDsBySurfaceID[currentSurfaceID, default: []].insert(sessionID) + } + } + + /// Returns all sessions currently bound to a surface. + /// + /// - Parameter surfaceID: Terminal surface UUID string. + /// - Returns: Session IDs associated with the surface. + public func sessionIDs(surfaceID: String) -> Set { + sessionIDsBySurfaceID[surfaceID] ?? [] + } + + /// Returns valid indexed sessions, rebuilding a missing surface entry from authoritative records. + /// + /// The full record dictionary is scanned only when the index has no valid + /// entry for `surfaceID`. Any recovered bindings are retained so later + /// lookups return directly from the index. + /// + /// - Parameters: + /// - surfaceID: Terminal surface UUID string. + /// - records: Authoritative records keyed by session identity. + /// - recordSurfaceID: Key path to each record's current surface binding. + /// - Returns: Session IDs currently associated with the surface. + public mutating func sessionIDs( + surfaceID: String, + healingFrom records: [SessionID: Record], + recordSurfaceID: KeyPath + ) -> Set { + let indexed = sessionIDsBySurfaceID[surfaceID] ?? [] + var validIndexed: Set = [] + for sessionID in indexed { + guard let record = records[sessionID], + record[keyPath: recordSurfaceID] == surfaceID else { + update( + sessionID: sessionID, + previousSurfaceID: surfaceID, + currentSurfaceID: nil + ) + continue + } + validIndexed.insert(sessionID) + } + guard validIndexed.isEmpty else { return validIndexed } + + var recovered: Set = [] + for (sessionID, record) in records + where record[keyPath: recordSurfaceID] == surfaceID { + recovered.insert(sessionID) + update( + sessionID: sessionID, + previousSurfaceID: nil, + currentSurfaceID: surfaceID + ) + } + return recovered + } +} diff --git a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift index 3d993df9e5b7..c38721378437 100644 --- a/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift +++ b/Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Source/ChatEventSource.swift @@ -9,6 +9,8 @@ import Foundation public protocol ChatEventSource: Sendable { /// Whether this source supports Mac-hosted artifact preview RPCs. var supportsArtifacts: Bool { get } + /// Whether this source supports recursive artifact folder browsing. + var supportsArtifactFolders: Bool { get } /// Fetches a page of transcript history for a session. /// @@ -82,6 +84,22 @@ public protocol ChatEventSource: Sendable { progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data + /// Streams raw chunks for a referenced artifact path. + /// + /// Implementations call `onChunk` in byte-offset order and await it before + /// requesting the next chunk, so cancellation and consumer backpressure + /// remain part of the fetch task. + /// + /// - Parameters: + /// - sessionID: The session whose transcript referenced the path. + /// - path: Absolute Mac host path. + /// - onChunk: Structured callback for each fetched chunk. + func artifactFetch( + sessionID: String, + path: String, + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws + /// Fetches a JPEG thumbnail for a referenced image artifact. /// /// - Parameters: @@ -108,6 +126,9 @@ public extension ChatEventSource { /// Unsupported-by-default artifact capability for fixtures and previews. var supportsArtifacts: Bool { false } + /// Unsupported-by-default recursive artifact folder capability. + var supportsArtifactFolders: Bool { false } + /// Unsupported-by-default artifact stat implementation. func artifactStat(sessionID: String, path: String) async throws -> ChatArtifactStat { throw ChatArtifactError.unsupported @@ -122,6 +143,24 @@ public extension ChatEventSource { throw ChatArtifactError.unsupported } + /// Whole-file fallback for sources that have not adopted chunk streaming. + func artifactFetch( + sessionID: String, + path: String, + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + let data = try await artifactFetch(sessionID: sessionID, path: path, progress: nil) + try Task.checkCancellation() + try await onChunk( + ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + ) + ) + } + /// Unsupported-by-default artifact thumbnail implementation. func artifactThumbnail( sessionID: String, diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift new file mode 100644 index 000000000000..1ba6947986f6 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ArtifactByteReaderTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("ArtifactByteReader") +struct ArtifactByteReaderTests { + @Test("directory listings cap at 500 and report truncation") + func listCap() throws { + try withTemporaryDirectory { directory in + for index in 0...ArtifactByteReader.maximumDirectoryEntryCount { + let path = directory.appendingPathComponent(String(format: "item-%03d.txt", index)) + #expect(FileManager.default.createFile(atPath: path.path, contents: Data())) + } + + let listing = try ArtifactByteReader().list(path: directory.path) + + #expect(listing.entries.count == ArtifactByteReader.maximumDirectoryEntryCount) + #expect(listing.isTruncated) + #expect(listing.entries.first?.name == "item-000.txt") + #expect(listing.entries.last?.name == "item-499.txt") + } + } + + @Test("listing a file keeps the existing file-not-found semantic") + func listingFile() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("artifact.txt") + #expect(FileManager.default.createFile(atPath: file.path, contents: Data("hello".utf8))) + + do { + _ = try ArtifactByteReader().list(path: file.path) + Issue.record("listing a file should fail") + } catch ArtifactByteReader.Error.fileNotFound { + // Expected wire semantic. + } catch { + Issue.record("unexpected error: \(error)") + } + } + } + + @Test("extensionless UTF-8 text is classified as text") + func extensionlessUTF8Text() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data("hello, 漢字 and 🙂".utf8).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("unknown-extension UTF-8 text is classified as text") + func unknownExtensionUTF8Text() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output.cmux-unknown-text-kind") + try Data("plain text".utf8).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("binary junk remains binary") + func binaryJunk() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data([0x00, 0xFF, 0xFE, 0x80]).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .binary) + } + } + + @Test("empty extensionless files are valid UTF-8 text") + func emptyFile() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data().write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("files smaller than the sniff budget are classified from all bytes") + func smallerThanSniffBudget() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + try Data(repeating: 0x61, count: 8 * 1024 - 1).write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + @Test("a multibyte scalar split at the 8 KiB edge is accepted") + func multibyteScalarSplitAtSniffEdge() throws { + try withTemporaryDirectory { directory in + let file = directory.appendingPathComponent("output") + var bytes = Data(repeating: 0x61, count: 8 * 1024 - 1) + bytes.append(contentsOf: "🙂".utf8) + try bytes.write(to: file) + + #expect(ArtifactByteReader().kind(path: file.path, isDirectory: false) == .text) + } + } + + private func withTemporaryDirectory( + _ operation: (URL) throws -> Void + ) throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-list-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + try operation(directory) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift new file mode 100644 index 000000000000..f0afc5ba9068 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactCanonicalIdentityTests.swift @@ -0,0 +1,168 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact canonical identity") +struct ChatArtifactCanonicalIdentityTests { + @Test("derive accepts an injected canonical identity operation") + func injectedCanonicalizerDeduplicates() { + let target = "/fixture/Report.md" + let symlink = "/fixture/report-link.md" + let canonical = "/canonical/Report.md" + let canonicalizer = ChatArtifactPathCanonicalizer { path in + [target: canonical, symlink: canonical][path] ?? path + } + + let records = ChatArtifactIndexedReference.derive( + from: [ + editMessage(id: "created", seq: 4, path: target), + toolMessage(id: "referenced", seq: 9, path: symlink), + ], + canonicalizer: canonicalizer + ) + + #expect(records == [ChatArtifactIndexedReference( + path: canonical, + provenance: .created, + lastReferencedSeq: 9 + )]) + } + + @Test("symlink and target spellings produce one row") + func symlinkAndTargetDeduplicate() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("Report.txt") + let symlink = root.appendingPathComponent("report-link.txt") + try Data("report".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: target) + + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "target", seq: 1, path: target.path), + toolMessage(id: "symlink", seq: 2, path: symlink.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(target.path), + provenance: .referenced, + lastReferencedSeq: 2 + )]) + } + + @Test("case variants produce one row with the on-disk spelling") + func caseVariantsUseOnDiskSpelling() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let onDisk = root.appendingPathComponent("MixedCaseReport.TXT") + let variant = root.appendingPathComponent("mixedcasereport.txt") + try Data("report".utf8).write(to: onDisk) + #expect(FileManager.default.fileExists(atPath: variant.path)) + + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "canonical-case", seq: 1, path: onDisk.path), + toolMessage(id: "variant-case", seq: 2, path: variant.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(onDisk.path), + provenance: .referenced, + lastReferencedSeq: 2 + )]) + } + + @Test("a deleted file remains listed under its lexical path") + func deletedFileRemainsLexical() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let deleted = root.appendingPathComponent("DeletedReport.TXT") + try Data("report".utf8).write(to: deleted) + try FileManager.default.removeItem(at: deleted) + + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "deleted", seq: 3, path: deleted.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(deleted.path), + provenance: .referenced, + lastReferencedSeq: 3 + )]) + } + + @Test("created provenance survives a later symlink reference and advances sequence") + func provenanceAndSequenceMergeAcrossSpellings() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("Created.swift") + let symlink = root.appendingPathComponent("created-link.swift") + try Data("let value = 1".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: target) + + let records = ChatArtifactIndexedReference.derive(from: [ + editMessage(id: "created", seq: 4, path: target.path), + toolMessage(id: "referenced", seq: 9, path: symlink.path), + ]) + + #expect(records == [ChatArtifactIndexedReference( + path: try lexicalPath(target.path), + provenance: .created, + lastReferencedSeq: 9 + )]) + } + + @Test("chip session total matches the deduplicated gallery total") + func chipTotalMatchesDeduplicatedGallery() throws { + let root = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let target = root.appendingPathComponent("ChipCount.md") + let symlink = root.appendingPathComponent("chip-count-link.md") + try Data("count".utf8).write(to: target) + try FileManager.default.createSymbolicLink(at: symlink, withDestinationURL: target) + let records = ChatArtifactIndexedReference.derive(from: [ + toolMessage(id: "target", seq: 1, path: target.path), + toolMessage(id: "symlink", seq: 2, path: symlink.path), + ]) + + let total = ChatArtifactGalleryOrdering().sessionTotal(records) + + #expect(total == records.count) + #expect(total == 1) + } + + private func makeTemporaryDirectory() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-identity-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func lexicalPath(_ path: String) throws -> String { + try #require(ChatArtifactPathNormalizer(workingDirectory: nil).structuredPath(path)) + } + + private func toolMessage(id: String, seq: Int, path: String) -> ChatMessage { + ChatMessage( + id: id, + seq: seq, + role: .agent, + timestamp: Date(timeIntervalSince1970: 0), + kind: .toolUse(ChatToolUse( + toolName: "Read", + summary: "read", + status: .succeeded, + referencedPaths: [path] + )) + ) + } + + private func editMessage(id: String, seq: Int, path: String) -> ChatMessage { + ChatMessage( + id: id, + seq: seq, + role: .agent, + timestamp: Date(timeIntervalSince1970: 0), + kind: .fileEdit(ChatFileEdit(filePath: path, operation: .write)) + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift new file mode 100644 index 000000000000..551a10db9763 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryClassifierTests.swift @@ -0,0 +1,117 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery classifier") +struct ChatArtifactGalleryClassifierTests { + struct Fixture: Sendable, CustomTestStringConvertible { + let path: String + let kind: ChatArtifactKind + let expected: ChatArtifactGalleryFilter? + let expectedGlyph: ChatArtifactGalleryGlyphPresentation + + var testDescription: String { "\(kind.rawValue):\(path)" } + } + + @Test(arguments: [ + Fixture( + path: "/tmp/photo.swift", + kind: .image, + expected: .images, + expectedGlyph: .init(systemImageName: "photo", tint: .secondary) + ), + Fixture( + path: "/tmp/folder.png", + kind: .directory, + expected: .folders, + expectedGlyph: .init(systemImageName: "folder", tint: .accent) + ), + Fixture( + path: "/tmp/App.swift", + kind: .text, + expected: .code, + expectedGlyph: .init( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) + ), + Fixture( + path: "/tmp/seed.py", + kind: .text, + expected: .code, + expectedGlyph: .init( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) + ), + Fixture( + path: "/tmp/main.CPP", + kind: .binary, + expected: .code, + expectedGlyph: .init( + systemImageName: "chevron.left.forwardslash.chevron.right", + tint: .accent + ) + ), + Fixture( + path: "/tmp/run.log", + kind: .text, + expected: .logs, + expectedGlyph: .init(systemImageName: "text.alignleft", tint: .accent) + ), + Fixture( + path: "/tmp/process.OUT", + kind: .binary, + expected: .logs, + expectedGlyph: .init(systemImageName: "text.alignleft", tint: .accent) + ), + Fixture( + path: "/tmp/report.PDF", + kind: .binary, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/report.docx", + kind: .binary, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/README.md", + kind: .text, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/notes.txt", + kind: .text, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/table.numbers", + kind: .binary, + expected: .docs, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + Fixture( + path: "/tmp/archive.zip", + kind: .binary, + expected: nil, + expectedGlyph: .init(systemImageName: "doc.text", tint: .secondary) + ), + Fixture( + path: "/tmp/LICENSE", + kind: .text, + expected: nil, + expectedGlyph: .init(systemImageName: "doc.text", tint: .accent) + ), + ]) + func classifiesKindAndExtensionMatrix(_ fixture: Fixture) { + let classifier = ChatArtifactGalleryClassifier() + + #expect(classifier.filter(for: fixture.kind, path: fixture.path) == fixture.expected) + #expect(classifier.glyphPresentation(for: fixture.kind, path: fixture.path) == fixture.expectedGlyph) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift new file mode 100644 index 000000000000..15cf28acb1d4 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryEagerPagerTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery eager pager") +struct ChatArtifactGalleryEagerPagerTests { + @Test("fetches cursors sequentially until exhausted") + func fetchesUntilExhausted() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two"], nextCursor: "cursor-2"), + "cursor-2": page(paths: ["/three"], nextCursor: nil), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager().loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map { $0.path } == ["/one", "/two", "/three"]) + #expect(result.snapshot.nextCursor == nil) + #expect(!result.reachedSafetyCap) + #expect(await script.requestedCursors() == ["cursor-1", "cursor-2"]) + } + + @Test("stops at the safety cap and retains the next cursor") + func respectsSafetyCap() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two", "/three"], nextCursor: "cursor-2"), + "cursor-2": page(paths: ["/four"], nextCursor: nil), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 4) + + let result = try await ChatArtifactGalleryEagerPager( + maximumReferencedRows: 3 + ).loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map { $0.path } == ["/one", "/two", "/three"]) + #expect(result.snapshot.nextCursor == "cursor-2") + #expect(result.reachedSafetyCap) + #expect(await script.requestedCursors() == ["cursor-1"]) + } + + @Test("caps an oversized final page even when its cursor is exhausted") + func capsOversizedFinalPage() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two", "/three"], nextCursor: nil), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager( + maximumReferencedRows: 2 + ).loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map { $0.path } == ["/one", "/two"]) + #expect(result.snapshot.nextCursor == nil) + #expect(result.reachedSafetyCap) + } + + @Test("stops before requesting a repeated cursor") + func stopsAtRepeatedCursor() async throws { + let script = ChatArtifactGalleryPageScript(pages: [ + "cursor-1": page(paths: ["/two"], nextCursor: "cursor-1"), + ]) + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager().loadRemaining(from: initial) { cursor in + try await script.fetch(cursor: cursor) + } + + #expect(result.snapshot.referenced.map(\.path) == ["/one", "/two"]) + #expect(result.snapshot.nextCursor == "cursor-1") + #expect(!result.reachedSafetyCap) + #expect(await script.requestedCursors() == ["cursor-1"]) + } + + @Test("surfaces a stale-generation restart without changing accumulated rows") + func staleGenerationRestart() async throws { + let initial = snapshot(paths: ["/one"], nextCursor: "cursor-1", total: 3) + + let result = try await ChatArtifactGalleryEagerPager().loadRemaining(from: initial) { _ in + ChatArtifactGalleryPage( + sessionID: "session", + generation: "new", + requiresPagingRestart: true + ) + } + + #expect(result.requiresPagingRestart) + #expect(!result.reachedSafetyCap) + #expect(result.snapshot == initial) + } + + private func snapshot( + paths: [String], + nextCursor: String?, + total: Int + ) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: paths.map(item), + referencedTotal: total, + nextCursor: nextCursor, + generation: "generation" + )) + } + + private func page( + paths: [String], + nextCursor: String? + ) -> ChatArtifactGalleryPage { + ChatArtifactGalleryPage( + sessionID: "session", + referenced: paths.map(item), + referencedTotal: paths.count, + nextCursor: nextCursor, + generation: "generation" + ) + } + + private func item(path: String) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: URL(fileURLWithPath: path).lastPathComponent + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryLiveRefreshStateTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryLiveRefreshStateTests.swift new file mode 100644 index 000000000000..eb647e837eb9 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryLiveRefreshStateTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery live refresh") +struct ChatArtifactGalleryLiveRefreshStateTests { + @Test("a top or fitting gallery merges fresh rows immediately") + func mergesAtTop() throws { + var state = ChatArtifactGalleryLiveRefreshState() + let displayed = snapshot(generation: "one", paths: ["/old.txt"]) + let fresh = snapshot(generation: "two", paths: ["/new.txt", "/old.txt"]) + + let received = state.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: true + ) + let merged = try #require(received) + + #expect(merged.referenced.map(\.path) == ["/new.txt", "/old.txt"]) + #expect(state.pendingNewFileCount == 0) + } + + @Test("a reading position keeps the visible snapshot and reports unseen paths") + func defersAwayFromTop() { + var state = ChatArtifactGalleryLiveRefreshState() + let displayed = snapshot(generation: "one", paths: ["/old.txt"]) + let fresh = snapshot(generation: "two", paths: ["/new-a.txt", "/new-b.txt", "/old.txt"]) + + let merged = state.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: false + ) + + #expect(merged == nil) + #expect(state.pendingNewFileCount == 2) + #expect(displayed.referenced.map(\.path) == ["/old.txt"]) + } + + @Test("applying a pending refresh clears the pill and preserves loaded history") + func appliesPendingRefresh() throws { + var state = ChatArtifactGalleryLiveRefreshState() + let displayed = snapshot(generation: "one", paths: ["/old.txt", "/older.txt"]) + let fresh = snapshot(generation: "two", paths: ["/new.txt", "/old.txt"]) + #expect(state.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: false + ) == nil) + + let pendingApplication = state.applyPending(to: displayed) + let applied = try #require(pendingApplication) + + #expect(applied.referenced.map(\.path) == ["/new.txt", "/old.txt", "/older.txt"]) + #expect(applied.referencedTotal == 3) + #expect(applied.generation == "two") + #expect(state.pendingNewFileCount == 0) + #expect(state.applyPending(to: applied) == nil) + } + + private func snapshot(generation: String, paths: [String]) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: paths.map { path in + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: URL(fileURLWithPath: path).lastPathComponent, + provenance: .referenced + ) + }, + referencedTotal: paths.count, + generation: generation + )) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryOrderingCacheTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryOrderingCacheTests.swift new file mode 100644 index 000000000000..73273a05a9db --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryOrderingCacheTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery ordering cache") +struct ChatArtifactGalleryOrderingCacheTests { + @Test("same generation reuses its ordering and a new generation invalidates it") + func generationReuseAndInvalidation() async { + let cache = ChatArtifactGalleryOrderingCache() + let original = [ + item("/older", seq: 1), + item("/newer", seq: 2), + ] + let changedWithoutGeneration = [item("/unexpected", seq: 100)] + let nextGeneration = [ + item("/latest", seq: 3), + item("/newer", seq: 2), + ] + + let first = await cache.ordered(original, indexID: "session", generation: "one") + let reused = await cache.ordered( + changedWithoutGeneration, + indexID: "session", + generation: "one" + ) + let invalidated = await cache.ordered( + nextGeneration, + indexID: "session", + generation: "two" + ) + + #expect(first.map(\.path) == ["/newer", "/older"]) + #expect(reused == first) + #expect(invalidated.map(\.path) == ["/latest", "/newer"]) + } + + private func item(_ path: String, seq: Int) -> ChatArtifactIndexedReference { + ChatArtifactIndexedReference( + path: path, + provenance: .referenced, + lastReferencedSeq: seq + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPageScript.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPageScript.swift new file mode 100644 index 000000000000..1b53273cc74d --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPageScript.swift @@ -0,0 +1,32 @@ +@testable import CmuxAgentChat + +/// Actor-backed page script that records sequential eager-paging requests. +actor ChatArtifactGalleryPageScript { + private let pages: [String: ChatArtifactGalleryPage] + private var cursors: [String] = [] + + /// Creates a page script keyed by opaque cursor. + /// + /// - Parameter pages: Pages returned for each expected cursor. + init(pages: [String: ChatArtifactGalleryPage]) { + self.pages = pages + } + + /// Records and resolves one cursor request. + /// + /// - Parameter cursor: Requested opaque cursor. + /// - Returns: Scripted page for the cursor. + /// - Throws: ``ChatArtifactError/invalidParams`` for an unscripted cursor. + func fetch(cursor: String) throws -> ChatArtifactGalleryPage { + cursors.append(cursor) + guard let page = pages[cursor] else { + throw ChatArtifactError.invalidParams + } + return page + } + + /// Returns cursors in request order. + func requestedCursors() -> [String] { + cursors + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift new file mode 100644 index 000000000000..2dd3fbed7c7b --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryPresentationTests.swift @@ -0,0 +1,132 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery presentation") +struct ChatArtifactGalleryPresentationTests { + @Test("filtering preserves group order and hides rows within every group") + func filterPreservesGroups() { + let snapshot = gallerySnapshot( + created: [item("/created/App.swift", kind: .text, size: 10)], + attached: [item("/attached/photo.png", kind: .image, size: 20)], + referenced: [ + item("/referenced/notes.txt", kind: .text, size: 30), + item("/referenced/Tool.swift", kind: .text, size: 40), + ] + ) + + let presentation = ChatArtifactGalleryPresentation( + snapshot: snapshot, + filter: .code + ) + + #expect(presentation.groups.map(\.kind) == [.created, .attached, .referenced]) + #expect(presentation.items(in: .created).map(\.displayName) == ["App.swift"]) + #expect(presentation.items(in: .attached).isEmpty) + #expect(presentation.items(in: .referenced).map(\.displayName) == ["Tool.swift"]) + } + + @Test("name and size sorts reorder only within each group") + func sortWithinGroups() { + let created = [ + item("/created/zeta.txt", kind: .text, size: nil), + item("/created/Alpha.txt", kind: .text, size: 1), + item("/created/middle.txt", kind: .text, size: 30), + ] + let referenced = [ + item("/referenced/b.txt", kind: .text, size: 5), + item("/referenced/a.txt", kind: .text, size: 10), + ] + let snapshot = gallerySnapshot(created: created, referenced: referenced) + + let named = ChatArtifactGalleryPresentation(snapshot: snapshot, sort: .name) + #expect(named.items(in: .created).map(\.displayName) == ["Alpha.txt", "middle.txt", "zeta.txt"]) + #expect(named.items(in: .referenced).map(\.displayName) == ["a.txt", "b.txt"]) + + let sized = ChatArtifactGalleryPresentation(snapshot: snapshot, sort: .size) + #expect(sized.items(in: .created).map(\.displayName) == ["middle.txt", "Alpha.txt", "zeta.txt"]) + #expect(sized.items(in: .referenced).map(\.displayName) == ["a.txt", "b.txt"]) + } + + @Test("missing files are omitted from every group by default") + func omitsMissingFilesByDefault() { + let snapshot = gallerySnapshot( + created: [ + item("/created/present.txt", kind: .text, size: 1), + item("/created/missing.txt", kind: .text, size: nil, exists: false), + ], + attached: [ + item("/attached/missing.png", kind: .image, size: nil, exists: false), + ], + referenced: [ + item("/referenced/present.log", kind: .text, size: 2), + item("/referenced/missing.log", kind: .text, size: nil, exists: false), + ] + ) + + let presentation = ChatArtifactGalleryPresentation(snapshot: snapshot) + + #expect(presentation.items(in: .created).map(\.displayName) == ["present.txt"]) + #expect(presentation.items(in: .attached).isEmpty) + #expect(presentation.items(in: .referenced).map(\.displayName) == ["present.log"]) + #expect(ChatArtifactGallerySwipeOrder(groups: presentation.groups).paths == [ + "/created/present.txt", + "/referenced/present.log", + ]) + } + + @Test("missing files can be restored across every group") + func includesMissingFilesOnRequest() { + let missingCreated = item("/created/missing.txt", kind: .text, size: nil, exists: false) + let missingAttached = item("/attached/missing.png", kind: .image, size: nil, exists: false) + let missingReferenced = item("/referenced/missing.log", kind: .text, size: nil, exists: false) + let presentation = ChatArtifactGalleryPresentation( + snapshot: gallerySnapshot( + created: [missingCreated], + attached: [missingAttached], + referenced: [missingReferenced] + ), + includesMissingFiles: true + ) + + #expect(presentation.items(in: .created) == [missingCreated]) + #expect(presentation.items(in: .attached) == [missingAttached]) + #expect(presentation.items(in: .referenced) == [missingReferenced]) + #expect(ChatArtifactGallerySwipeOrder(groups: presentation.groups).paths == [ + missingCreated.path, + missingAttached.path, + missingReferenced.path, + ]) + } + + private func gallerySnapshot( + created: [ChatArtifactGalleryItem] = [], + attached: [ChatArtifactGalleryItem] = [], + referenced: [ChatArtifactGalleryItem] = [] + ) -> ChatArtifactGallerySnapshot { + ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + created: created, + attached: attached, + referenced: referenced, + referencedTotal: referenced.count, + generation: "generation" + )) + } + + private func item( + _ path: String, + kind: ChatArtifactKind, + size: Int64?, + exists: Bool = true + ) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: kind, + displayName: URL(fileURLWithPath: path).lastPathComponent, + size: size, + exists: exists + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift new file mode 100644 index 000000000000..0e88dcea3e12 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySnapshotTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery snapshot") +struct ChatArtifactGallerySnapshotTests { + @Test("appending drops paths already present in any section") + func appendingDeduplicatesAcrossSections() { + let created = item(path: "/session/created.txt", provenance: .created) + let attached = item(path: "/session/attached.txt", provenance: .attached) + let referenced = item(path: "/session/referenced.txt", provenance: .referenced) + let next = item(path: "/session/next.txt", provenance: .referenced) + let initial = ChatArtifactGalleryPage( + sessionID: "session", + created: [created], + attached: [attached], + referenced: [referenced], + referencedTotal: 2, + nextCursor: "next", + generation: "generation" + ) + let page = ChatArtifactGalleryPage( + sessionID: "session", + created: [created, item(path: "/session/created-next.txt", provenance: .created)], + createdTotal: 2, + attached: [attached, item(path: "/session/attached-next.txt", provenance: .attached)], + attachedTotal: 2, + referenced: [created, attached, referenced, next, next], + referencedTotal: 2, + generation: "generation" + ) + + let snapshot = ChatArtifactGallerySnapshot(page: initial).appending(page) + + #expect(snapshot.created.map(\.path) == [created.path, "/session/created-next.txt"]) + #expect(snapshot.attached.map(\.path) == [attached.path, "/session/attached-next.txt"]) + #expect(snapshot.referenced.map(\.path) == [referenced.path, next.path]) + #expect(snapshot.createdTotal == 2) + #expect(snapshot.attachedTotal == 2) + } + + @Test("stale response is ignored and fresh rebase preserves exact row order") + func staleRestartRebase() { + let oldA = item(path: "/old-a", provenance: .referenced) + let oldB = item(path: "/old-b", provenance: .referenced) + let current = ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: [oldA, oldB], + referencedTotal: 4, + nextCursor: "stale", + generation: "old" + )) + let stale = ChatArtifactGalleryPage( + sessionID: "session", + generation: "new", + requiresPagingRestart: true + ) + let fresh = ChatArtifactGallerySnapshot(page: ChatArtifactGalleryPage( + sessionID: "session", + referenced: [item(path: "/new", provenance: .referenced), oldA], + referencedTotal: 5, + nextCursor: "fresh", + generation: "new" + )) + + #expect(current.appending(stale) == current) + let rebased = current.rebasingPaging(ontoFreshFirstPage: fresh) + #expect(rebased.referenced == current.referenced) + #expect(rebased.nextCursor == "fresh") + #expect(rebased.generation == "new") + #expect(rebased.referencedTotal == 5) + } + + private func item( + path: String, + provenance: ChatArtifactProvenance + ) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: (path as NSString).lastPathComponent, + provenance: provenance + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySwipeOrderTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySwipeOrderTests.swift new file mode 100644 index 000000000000..084663282e6a --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGallerySwipeOrderTests.swift @@ -0,0 +1,74 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact gallery swipe order") +struct ChatArtifactGallerySwipeOrderTests { + @Test("flattens visible groups and skips folders") + func flattensVisibleFiles() { + let first = item("/created/first.swift") + let folder = item("/attached/folder", kind: .directory) + let second = item("/attached/second.png", kind: .image) + let third = item("/referenced/third.log") + let order = ChatArtifactGallerySwipeOrder(groups: [ + ChatArtifactGalleryGroup(kind: .created, items: [first]), + ChatArtifactGalleryGroup(kind: .attached, items: [folder, second]), + ChatArtifactGalleryGroup(kind: .referenced, items: [third, first]), + ]) + + #expect(order.paths == [first.path, second.path, third.path]) + } + + @Test("next and previous stop at visible boundaries") + func nextPreviousBoundaries() { + let first = item("/first") + let middle = item("/middle") + let last = item("/last") + let order = ChatArtifactGallerySwipeOrder(items: [first, middle, last]) + + #expect(order.previousPath(before: first.path) == nil) + #expect(order.nextPath(after: first.path) == middle.path) + #expect(order.previousPath(before: middle.path) == first.path) + #expect(order.nextPath(after: middle.path) == last.path) + #expect(order.previousPath(before: last.path) == middle.path) + #expect(order.nextPath(after: last.path) == nil) + #expect(order.nextPath(after: "/unknown") == nil) + #expect(order.previousPath(before: "/unknown") == nil) + } + + @Test("keeps only adjacent pages alive around the current file") + func boundsPageWindow() { + let first = item("/first") + let second = item("/second") + let third = item("/third") + let fourth = item("/fourth") + let order = ChatArtifactGallerySwipeOrder(items: [first, second, third, fourth]) + + #expect(order.pageWindow(around: first.path).map(\.path) == [first.path, second.path]) + #expect(order.pageWindow(around: second.path).map(\.path) == [first.path, second.path, third.path]) + #expect(order.pageWindow(around: fourth.path).map(\.path) == [third.path, fourth.path]) + #expect(order.pageWindow(around: "/unknown").isEmpty) + } + + @Test("terminal references preserve order while excluding folders") + func buildsFromTerminalReferences() { + let references = [ + TerminalArtifactReference(path: "/first", kind: .text, displayName: "first"), + TerminalArtifactReference(path: "/folder", kind: .directory, displayName: "folder"), + TerminalArtifactReference(path: "/last", kind: .image, displayName: "last"), + ] + + #expect(ChatArtifactGallerySwipeOrder(references: references).paths == ["/first", "/last"]) + } + + private func item( + _ path: String, + kind: ChatArtifactKind = .text + ) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: kind, + displayName: path + ) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift index a08b0242a6cf..225e7b32971f 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactGalleryTests.swift @@ -13,6 +13,8 @@ struct ChatArtifactGalleryTests { size: 42, modifiedAt: Date(timeIntervalSince1970: 123), exists: false, + childCount: 500, + childCountIsCapped: true, provenance: .created ) let page = ChatArtifactGalleryPage( @@ -26,8 +28,13 @@ struct ChatArtifactGalleryTests { let data = try coding.encode(page) let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) #expect(json["session_id"] as? String == "session-1") + #expect(json["created_total"] as? Int == 1) + #expect(json["attached_total"] as? Int == 0) #expect(json["referenced_total"] as? Int == 7) #expect(json["next_cursor"] as? String == "cursor") + let created = try #require(json["created"] as? [[String: Any]]) + #expect(created.first?["child_count"] as? Int == 500) + #expect(created.first?["child_count_is_capped"] as? Bool == true) #expect(try coding.decode(ChatArtifactGalleryPage.self, from: data) == page) let scan = TerminalArtifactScanResponse(artifacts: [], sessionID: "session-1") @@ -41,6 +48,8 @@ struct ChatArtifactGalleryTests { let itemData = Data(#"{"path":"/tmp/old.txt","kind":"text","display_name":"old.txt"}"#.utf8) let item = try coding.decode(ChatArtifactGalleryItem.self, from: itemData) #expect(item.exists) + #expect(item.childCount == nil) + #expect(!item.childCountIsCapped) #expect(item.provenance == .referenced) let scanData = Data(#"{"artifacts":[]}"#.utf8) @@ -50,6 +59,220 @@ struct ChatArtifactGalleryTests { #expect(scan.artifacts.isEmpty) } + @Test("directory rows and child counts require folder capability") + func directoryRowsAreCapabilityGated() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-gallery-\(UUID().uuidString)", isDirectory: true) + let folder = root.appendingPathComponent("folder", isDirectory: true) + let nested = folder.appendingPathComponent("nested", isDirectory: true) + let file = root.appendingPathComponent("notes.txt") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + #expect(FileManager.default.createFile( + atPath: folder.appendingPathComponent("child.txt").path, + contents: Data() + )) + #expect(FileManager.default.createFile(atPath: file.path, contents: Data())) + defer { try? FileManager.default.removeItem(at: root) } + + let references = [ + ChatArtifactIndexedReference( + path: folder.path, + provenance: .referenced, + lastReferencedSeq: 2 + ), + ChatArtifactIndexedReference( + path: file.path, + provenance: .referenced, + lastReferencedSeq: 1 + ), + ] + let builder = ChatArtifactGalleryBuilder() + let legacy = builder.page( + sessionID: "session", + items: references, + generation: "generation", + cursor: nil, + pageSize: 10, + query: nil + ) + #expect(legacy.referenced.map(\.path) == [file.path]) + #expect(legacy.referencedTotal == 2) + + let folders = builder.page( + sessionID: "session", + items: references, + generation: "generation", + cursor: nil, + pageSize: 10, + query: nil, + includeDirectories: true + ) + #expect(folders.referenced.map(\.path) == [folder.path, file.path]) + let folderItem = try #require(folders.referenced.first) + #expect(folderItem.kind == .directory) + #expect(folderItem.childCount == 2) + #expect(!folderItem.childCountIsCapped) + + let oldHostPage = ChatArtifactGalleryPage( + sessionID: "session", + created: [folderItem], + referenced: [folderItem], + referencedTotal: 1 + ).excludingDirectories() + #expect(oldHostPage.created.isEmpty) + #expect(oldHostPage.referenced.isEmpty) + #expect(oldHostPage.referencedTotal == 0) + } + + @Test("pages fill sections sequentially so loads only extend the list bottom") + func allSectionsPageLazily() throws { + let items = (1...3).flatMap { index in + [ + ChatArtifactIndexedReference( + path: "/missing/created-\(index).txt", + provenance: .created, + lastReferencedSeq: index + ), + ChatArtifactIndexedReference( + path: "/missing/attached-\(index).txt", + provenance: .attached, + lastReferencedSeq: index + ), + ChatArtifactIndexedReference( + path: "/missing/referenced-\(index).txt", + provenance: .referenced, + lastReferencedSeq: index + ), + ] + } + let builder = ChatArtifactGalleryBuilder() + + let first = builder.page( + sessionID: "session", + items: items, + generation: "generation", + cursor: nil, + pageSize: 2, + query: nil, + includeDirectories: true + ) + let cursor = try #require(first.nextCursor.flatMap(ChatArtifactGalleryCursor.init(token:))) + let second = builder.page( + sessionID: "session", + items: items, + generation: "generation", + cursor: cursor, + pageSize: 2, + query: nil, + includeDirectories: true + ) + let snapshot = ChatArtifactGallerySnapshot(page: first).appending(second) + + // Sequential fill: the first page is created rows only, and every + // later page extends the grouped list strictly at its bottom, so a + // scroll-triggered load never inserts rows into an earlier group. + #expect(first.created.count == 2) + #expect(first.attached.isEmpty) + #expect(first.referenced.isEmpty) + #expect(first.createdTotal == 3) + #expect(first.attachedTotal == 3) + #expect(first.referencedTotal == 3) + #expect(second.created.count == 1) + #expect(second.attached.count == 1) + #expect(second.referenced.isEmpty) + + var accumulated = snapshot + var nextToken = second.nextCursor + var guardCounter = 0 + while let token = nextToken, guardCounter < 8 { + let cursor = try #require(ChatArtifactGalleryCursor(token: token)) + let page = builder.page( + sessionID: "session", + items: items, + generation: "generation", + cursor: cursor, + pageSize: 2, + query: nil, + includeDirectories: true + ) + if !accumulated.attached.isEmpty { + #expect(page.created.isEmpty) + } + if !accumulated.referenced.isEmpty { + #expect(page.created.isEmpty) + #expect(page.attached.isEmpty) + } + accumulated = accumulated.appending(page) + nextToken = page.nextCursor + guardCounter += 1 + } + + #expect(nextToken == nil) + #expect(accumulated.created.count == 3) + #expect(accumulated.attached.count == 3) + #expect(accumulated.referenced.count == 3) + } + + @Test("a cursor from another generation requests a fresh paging restart") + func staleGenerationCursor() throws { + let staleCursor = ChatArtifactGalleryCursor( + generation: "old", + seq: 10, + path: "/old" + ) + + let page = ChatArtifactGalleryBuilder().page( + sessionID: "session", + items: [ChatArtifactIndexedReference( + path: "/new", + provenance: .referenced, + lastReferencedSeq: 20 + )], + generation: "new", + cursor: staleCursor, + pageSize: 10, + query: nil + ) + + #expect(page.requiresPagingRestart) + #expect(page.generation == "new") + #expect(page.created.isEmpty) + #expect(page.attached.isEmpty) + #expect(page.referenced.isEmpty) + #expect(page.nextCursor == nil) + } + + @Test("directory child counts stop at the listing cap") + func directoryChildCountCap() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-gallery-cap-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + for index in 0...ArtifactByteReader.maximumDirectoryEntryCount { + #expect(FileManager.default.createFile( + atPath: root.appendingPathComponent("item-\(index)").path, + contents: Data() + )) + } + + let page = ChatArtifactGalleryBuilder().page( + sessionID: "session", + items: [ChatArtifactIndexedReference( + path: root.path, + provenance: .referenced, + lastReferencedSeq: 1 + )], + generation: "generation", + cursor: nil, + pageSize: 10, + query: nil, + includeDirectories: true + ) + let folder = try #require(page.referenced.first) + #expect(folder.childCount == ArtifactByteReader.maximumDirectoryEntryCount) + #expect(folder.childCountIsCapped) + } + @Test("count-only scan matches every Session section without stat filtering") func sessionCountScan() throws { let records = [ diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactLRUCacheTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactLRUCacheTests.swift new file mode 100644 index 000000000000..8cacb578d3d5 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactLRUCacheTests.swift @@ -0,0 +1,21 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat artifact LRU cache") +struct ChatArtifactLRUCacheTests { + @Test("a cache hit protects the entry from capacity eviction") + func hitUpdatesRecency() { + var cache = ChatArtifactLRUCache(capacity: 2) + cache.insert(1, forKey: "one") + cache.insert(2, forKey: "two") + + #expect(cache.value(forKey: "one") == 1) + cache.insert(3, forKey: "three") + + #expect(cache.count == 2) + #expect(cache.value(forKey: "one") == 1) + #expect(cache.value(forKey: "two") == nil) + #expect(cache.value(forKey: "three") == 3) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift index 0a6c8b7e40e1..94961173f317 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactScopeTests.swift @@ -23,6 +23,30 @@ struct ChatArtifactScopeTests { #expect(scope.canonicalFilePath(for: "/safe/dir/nested/image.png") == nil) } + @Test("subtree mode allows nested descendants and nested directory listings") + func subtreeAllowsNestedDescendants() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir", "/safe/dir/nested"], + accessMode: .subtree + ) + #expect(scope.canonicalFilePath(for: "/safe/dir/nested/image.png") == "/safe/dir/nested/image.png") + #expect(scope.canonicalDirectoryListPath(for: "/safe/dir/nested") == "/safe/dir/nested") + } + + @Test("one-level mode preserves legacy file and list authorization") + func oneLevelParity() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir", "/safe/dir/nested"], + accessMode: .oneLevel + ) + #expect(scope.canonicalFilePath(for: "/safe/dir/image.png") == "/safe/dir/image.png") + #expect(scope.canonicalFilePath(for: "/safe/dir/nested/image.png") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/safe/dir") == "/safe/dir") + #expect(scope.canonicalDirectoryListPath(for: "/safe/dir/nested") == nil) + } + @Test("denies parent traversal escape") func deniesParentTraversalEscape() { let scope = scope(referenced: ["/safe/dir"], directories: ["/safe/dir"]) @@ -39,6 +63,30 @@ struct ChatArtifactScopeTests { #expect(scope.canonicalFilePath(for: "/safe/dir/link") == nil) } + @Test("subtree mode denies a symlink escape before containment comparison") + func subtreeDeniesSymlinkEscape() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir"], + symlinks: ["/safe/dir/nested/link": "/outside/secret.txt"], + accessMode: .subtree + ) + #expect(scope.canonicalFilePath(for: "/safe/dir/nested/link") == nil) + } + + @Test("subtree mode uniformly denies existing and missing paths outside scope") + func subtreeUniformOutsideDenial() { + let scope = scope( + referenced: ["/safe/dir"], + directories: ["/safe/dir", "/outside/existing"], + accessMode: .subtree + ) + #expect(scope.canonicalFilePath(for: "/outside/existing/file.txt") == nil) + #expect(scope.canonicalFilePath(for: "/outside/missing/file.txt") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/existing") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/missing") == nil) + } + @Test("denies relative path") func deniesRelativePath() { let scope = scope(referenced: ["/safe/file.txt"]) @@ -64,10 +112,12 @@ struct ChatArtifactScopeTests { private func scope( referenced: Set, directories: Set = [], - symlinks: [String: String] = [:] + symlinks: [String: String] = [:], + accessMode: ChatArtifactScope.DirectoryAccessMode = .oneLevel ) -> ChatArtifactScope { ChatArtifactScope( referencedPaths: referenced, + directoryAccessMode: accessMode, resolver: FakeResolver(directories: directories, symlinks: symlinks) ) } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift index 44bda5f8e6b2..2c9d9e19e712 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatArtifactTransferPolicyTests.swift @@ -8,6 +8,8 @@ struct ChatArtifactTransferPolicyTests { func defaultChunkFitsFrame() { let policy = ChatArtifactTransferPolicy.defaultPolicy #expect(policy.maxRawChunkBytes == 3 * 1024 * 1024) + #expect(policy.maxPreviewBytes == 64 * 1024 * 1024) + #expect(policy.maxMediaPreviewBytes == 512 * 1024 * 1024) #expect(policy.estimatedEnvelopeByteCount(rawByteCount: policy.maxRawChunkBytes) < policy.mobileSyncFrameLimitBytes) #expect(policy.clampedChunkLength(10 * 1024 * 1024) == policy.maxRawChunkBytes) } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift new file mode 100644 index 000000000000..dbdc60352864 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatSessionSurfaceIndexTests.swift @@ -0,0 +1,69 @@ +import Testing + +@testable import CmuxAgentChat + +@Suite("Chat session surface index") +struct ChatSessionSurfaceIndexTests { + private struct Record { + let sessionID: String + var surfaceID: String? + } + + @Test("indexed candidates stay equivalent to a full record scan") + func behaviorParityWithScan() { + var records: [String: Record] = [:] + var index = ChatSessionSurfaceIndex() + + func expectParity(surfaceID: String) { + let scanned = Set(records.values.lazy + .filter { $0.surfaceID == surfaceID } + .map(\.sessionID)) + #expect(index.sessionIDs(surfaceID: surfaceID) == scanned) + } + + records["one"] = Record(sessionID: "one", surfaceID: "surface-a") + index.update(sessionID: "one", previousSurfaceID: nil, currentSurfaceID: "surface-a") + records["two"] = Record(sessionID: "two", surfaceID: "surface-a") + index.update(sessionID: "two", previousSurfaceID: nil, currentSurfaceID: "surface-a") + records["unbound"] = Record(sessionID: "unbound", surfaceID: nil) + index.update(sessionID: "unbound", previousSurfaceID: nil, currentSurfaceID: nil) + expectParity(surfaceID: "surface-a") + + records["one"]?.surfaceID = "surface-b" + index.update( + sessionID: "one", + previousSurfaceID: "surface-a", + currentSurfaceID: "surface-b" + ) + expectParity(surfaceID: "surface-a") + expectParity(surfaceID: "surface-b") + + let removed = records.removeValue(forKey: "two") + index.update( + sessionID: "two", + previousSurfaceID: removed?.surfaceID, + currentSurfaceID: nil + ) + expectParity(surfaceID: "surface-a") + expectParity(surfaceID: "surface-b") + } + + @Test("an index miss scans authoritative records once and self-heals") + func selfHealsFromAuthoritativeRecords() { + let records = [ + "one": Record(sessionID: "one", surfaceID: "surface-a"), + "two": Record(sessionID: "two", surfaceID: "surface-a"), + "other": Record(sessionID: "other", surfaceID: "surface-b"), + ] + var index = ChatSessionSurfaceIndex() + + let healed = index.sessionIDs( + surfaceID: "surface-a", + healingFrom: records, + recordSurfaceID: \.surfaceID + ) + + #expect(healed == ["one", "two"]) + #expect(index.sessionIDs(surfaceID: "surface-a") == healed) + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactAuthorizationStoreTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactAuthorizationStoreTests.swift new file mode 100644 index 000000000000..0bd33c8c93c6 --- /dev/null +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactAuthorizationStoreTests.swift @@ -0,0 +1,98 @@ +import Foundation +import Testing + +@testable import CmuxAgentChat + +@Suite("Terminal artifact authorization snapshots") +struct TerminalArtifactAuthorizationStoreTests { + @Test("a listed path remains authorized after it scrolls off screen") + func listedPathSurvivesLiveScreenChange() async { + let listedPath = "/safe/report.txt" + let scanScope = TerminalArtifactScope( + terminalText: "open \(listedPath)", + workingDirectory: "/safe", + resolver: FakeResolver(files: [listedPath]) + ) + let listedPaths = Set(scanScope.artifactPaths()) + let liveScopeAfterScroll = TerminalArtifactScope( + terminalText: "prompt with no artifact path", + workingDirectory: "/safe", + resolver: FakeResolver(files: [listedPath]) + ) + let store = TerminalArtifactAuthorizationStore( + timeToLive: 60, + maximumGenerationsPerSurface: 2 + ) + let scannedAt = Date(timeIntervalSince1970: 100) + + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: listedPaths, + at: scannedAt + ) + let snapshotPaths = await store.authorizedPaths( + workspaceID: "workspace", + surfaceID: "surface", + at: scannedAt.addingTimeInterval(30) + ) + let snapshotScope = ChatArtifactScope( + referencedPaths: snapshotPaths, + resolver: FakeResolver(files: [listedPath]) + ) + + #expect(liveScopeAfterScroll.canonicalPath(for: listedPath) == nil) + #expect(snapshotScope.canonicalFilePath(for: listedPath) == listedPath) + } + + @Test("authorization is bounded by TTL and retained generations") + func boundedLifetimeAndGenerations() async { + let store = TerminalArtifactAuthorizationStore( + timeToLive: 60, + maximumGenerationsPerSurface: 2 + ) + let start = Date(timeIntervalSince1970: 100) + + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: ["/safe/one.txt"], + at: start + ) + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: ["/safe/two.txt"], + at: start.addingTimeInterval(1) + ) + await store.record( + workspaceID: "workspace", + surfaceID: "surface", + canonicalPaths: ["/safe/three.txt"], + at: start.addingTimeInterval(2) + ) + + #expect(await store.authorizedPaths( + workspaceID: "workspace", + surfaceID: "surface", + at: start.addingTimeInterval(30) + ) == ["/safe/two.txt", "/safe/three.txt"]) + #expect(await store.authorizedPaths( + workspaceID: "workspace", + surfaceID: "surface", + at: start.addingTimeInterval(63) + ).isEmpty) + } + + private struct FakeResolver: ChatArtifactScope.FileSystemResolving { + let files: Set + + func resolveSymlinks(of path: String) -> String? { + (path as NSString).standardizingPath + } + + func isDirectory(_ path: String) -> Bool? { + files.contains(path) ? false : nil + } + } +} diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift index 0b28615d9f8d..d74055974224 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactScopeTests.swift @@ -52,6 +52,96 @@ struct TerminalArtifactScopeTests { #expect(scope.canonicalPath(for: "/safe/project/src/main.swift") == "/safe/project/src/main.swift") } + @Test("subtree mode authorizes descendants of a visible directory") + func subtreeDirectoryAuthorization() { + let scope = scope( + text: "ls /safe/project", + accessMode: .subtree + ) + #expect(scope.canonicalPath(for: "/safe/project/src/main.swift") == "/safe/project/src/main.swift") + #expect(scope.canonicalDirectoryListPath(for: "/safe/project/src") == "/safe/project/src") + } + + @Test("one-level mode preserves visible-directory parity") + func oneLevelDirectoryAuthorization() { + let scope = scope( + text: "ls /safe/project", + accessMode: .oneLevel + ) + #expect(scope.canonicalPath(for: "/safe/project/readme.md") == "/safe/project/readme.md") + #expect(scope.canonicalPath(for: "/safe/project/src/main.swift") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/safe/project") == "/safe/project") + #expect(scope.canonicalDirectoryListPath(for: "/safe/project/src") == nil) + } + + @Test("subtree mode denies a symlink escape from a visible directory") + func subtreeDeniesSymlinkEscape() { + let scope = scope( + text: "ls /safe/project", + files: ["/outside/secret.txt"], + symlinks: ["/safe/project/src/link": "/outside/secret.txt"], + accessMode: .subtree + ) + #expect(scope.canonicalPath(for: "/safe/project/src/link") == nil) + } + + @Test("subtree mode uniformly denies existing and missing paths outside visible scope") + func subtreeUniformOutsideDenial() { + let scope = scope( + text: "ls /safe/project", + files: ["/outside/existing.txt"], + accessMode: .subtree + ) + #expect(scope.canonicalPath(for: "/outside/existing.txt") == nil) + #expect(scope.canonicalPath(for: "/outside/missing.txt") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/existing") == nil) + #expect(scope.canonicalDirectoryListPath(for: "/outside/missing") == nil) + } + + @Test("an exact visible file reaches the reader for list file-not-found semantics") + func exactVisibleFileMayReachListReader() { + let scope = scope(text: "cat /safe/project/src/main.swift") + #expect(scope.canonicalDirectoryListPath(for: "/safe/project/src/main.swift") == "/safe/project/src/main.swift") + } + @Test("visible scan deduplicates canonical identities in first-seen order") + func visibleScanCanonicalIdentityDeduplication() { + let canonicalizer = ChatArtifactPathCanonicalizer { path in + path == "/safe/report.txt" ? "/safe/Report.txt" : path + } + let scope = TerminalArtifactScope( + terminalText: "cat /safe/report.txt /safe/notes.txt /safe/Report.txt", + workingDirectory: "/safe", + resolver: FakeResolver( + files: ["/safe/report.txt", "/safe/notes.txt", "/safe/Report.txt"], + directories: ["/safe"], + symlinks: [:] + ), + canonicalizer: canonicalizer + ) + + #expect(scope.artifactPaths() == ["/safe/Report.txt", "/safe/notes.txt"]) + } + + @Test("request resolution shares the visible-scan canonical identity") + func requestResolutionUsesCanonicalIdentity() { + let canonicalizer = ChatArtifactPathCanonicalizer { path in + path == "/safe/report.txt" ? "/safe/Report.txt" : path + } + let scope = TerminalArtifactScope( + terminalText: "cat /safe/report.txt", + workingDirectory: "/safe", + resolver: FakeResolver( + files: ["/safe/report.txt", "/safe/Report.txt"], + directories: ["/safe"], + symlinks: [:] + ), + canonicalizer: canonicalizer + ) + + #expect(scope.canonicalPath(for: "/safe/report.txt") == "/safe/Report.txt") + #expect(scope.canonicalPath(for: "/safe/Report.txt") == "/safe/Report.txt") + } + private func scope( text: String, workingDirectory: String? = "/safe/project", @@ -61,12 +151,14 @@ struct TerminalArtifactScopeTests { "/safe/project/notes/todo.md", ], directories: Set = ["/safe", "/safe/project", "/safe/project/src", "/safe/project/notes"], - symlinks: [String: String] = [:] + symlinks: [String: String] = [:], + accessMode: ChatArtifactScope.DirectoryAccessMode = .oneLevel ) -> TerminalArtifactScope { TerminalArtifactScope( terminalText: text, workingDirectory: workingDirectory, - resolver: FakeResolver(files: files, directories: directories, symlinks: symlinks) + resolver: FakeResolver(files: files, directories: directories, symlinks: symlinks), + directoryAccessMode: accessMode ) } diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift index 3c0650b0688d..6345a6420077 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalArtifactTapHitTesterTests.swift @@ -85,6 +85,44 @@ struct TerminalArtifactTapHitTesterTests { #expect(resolved == path) } + @Test("resolves a bare filename referenced by terminal transcript prose") + func resolvesBareFilename() { + let resolved = TerminalArtifactTapHitTester().path( + in: "Inspect data.csv, then continue.", + col: 10, + row: 0, + columns: 80 + ) + + #expect(resolved == "data.csv") + } + + @Test("trims sentence punctuation from a bare filename") + func trimsBareFilenamePunctuation() { + let resolved = TerminalArtifactTapHitTester().path( + in: "The log is build.log.", + col: 15, + row: 0, + columns: 80 + ) + + #expect(resolved == "build.log") + } + + @Test("uses terminal cell width for CJK text before a path") + func cjkPrefixUsesTerminalColumns() { + let path = "/tmp/note.txt" + + let resolved = TerminalArtifactTapHitTester().path( + in: "漢字 open \(path)", + col: 22, + row: 0, + columns: 80 + ) + + #expect(resolved == path) + } + @Test("does not stitch a full-width token to a new prompt") func doesNotStitchPrompt() { let path = "/tmp/exact.md" diff --git a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift index f405a0cab506..2cd70a64d72c 100644 --- a/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift +++ b/Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/TerminalWireCodableTests.swift @@ -137,4 +137,24 @@ struct TerminalWireCodableTests { let newerResponse = try ChatWireCoding().decode(TerminalArtifactScanResponse.self, from: newer) #expect(newerResponse.sessionArtifactTotal == 9) } + + @Test("terminal directory listing round-trips cap metadata and decodes legacy payloads") + func terminalDirectoryListingRoundTrip() throws { + let listing = ChatArtifactDirectoryListing( + entries: [ + ChatArtifactDirectoryEntry(name: "Sources", isDirectory: true, size: 0, kind: .directory), + ChatArtifactDirectoryEntry(name: "README.md", isDirectory: false, size: 42, kind: .text), + ], + isTruncated: true + ) + let coding = ChatWireCoding() + let data = try coding.encode(listing) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + + #expect(object["is_truncated"] as? Bool == true) + #expect(try coding.decode(ChatArtifactDirectoryListing.self, from: data) == listing) + + let legacy = Data(#"{"entries":[]}"#.utf8) + #expect(try coding.decode(ChatArtifactDirectoryListing.self, from: legacy).isTruncated == false) + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Package.resolved b/Packages/iOS/CmuxAgentChatUI/Package.resolved new file mode 100644 index 000000000000..d5d0d404b1a7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "360573bfc5c7f7a8fdde3df27bac8472e0c3938aa9fb7f8bd43b1def104914e8", + "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/iOS/CmuxAgentChatUI/Package.swift b/Packages/iOS/CmuxAgentChatUI/Package.swift index b9629130545d..99c2672bc7d3 100644 --- a/Packages/iOS/CmuxAgentChatUI/Package.swift +++ b/Packages/iOS/CmuxAgentChatUI/Package.swift @@ -18,6 +18,10 @@ let package = Package( dependencies: [ .package(path: "../../Shared/CmuxAgentChat"), .package(path: "../CmuxMobileSupport"), + .package( + url: "https://github.com/raspu/Highlightr.git", + exact: "2.3.0" + ), ], targets: [ .target( @@ -25,6 +29,7 @@ let package = Package( dependencies: [ "CmuxAgentChat", "CmuxMobileSupport", + .product(name: "Highlightr", package: "Highlightr"), ], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v6)] diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift new file mode 100644 index 000000000000..daa5f33105ee --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCache.swift @@ -0,0 +1,203 @@ +import CmuxAgentChat +import CryptoKit +import Foundation + +/// Memory-fronted, purgeable disk LRU for fully fetched artifact bytes. +public actor ChatArtifactContentCache { + private let directory: URL + private let maxDiskBytes: Int64 + private let maxMemoryBytes: Int + private let fileManager = FileManager() + private let memoryCache = NSCache() + + /// Creates a content cache rooted at an injected directory. + /// + /// - Parameters: + /// - directory: Purgeable cache directory. + /// - maxDiskBytes: Maximum aggregate disk usage. + /// - maxMemoryBytes: Approximate `NSCache` byte-cost limit. + public init( + directory: URL, + maxDiskBytes: Int64 = 256 * 1_024 * 1_024, + maxMemoryBytes: Int = 32 * 1_024 * 1_024 + ) { + self.directory = directory + self.maxDiskBytes = max(0, maxDiskBytes) + self.maxMemoryBytes = max(0, maxMemoryBytes) + memoryCache.totalCostLimit = self.maxMemoryBytes + } + + /// Creates the production cache in `Caches//artifact-content/`. + /// + /// - Parameters: + /// - maxDiskBytes: Maximum aggregate disk usage. + /// - maxMemoryBytes: Approximate `NSCache` byte-cost limit. + /// - fileManager: Filesystem locator used to resolve the caches directory. + /// - bundleIdentifier: Bundle-specific directory component. + /// - Returns: A cache configured for application use. + public static func applicationDefault( + maxDiskBytes: Int64 = 256 * 1_024 * 1_024, + maxMemoryBytes: Int = 32 * 1_024 * 1_024, + fileManager: FileManager = .default, + bundleIdentifier: String? = Bundle.main.bundleIdentifier + ) -> ChatArtifactContentCache { + let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let bundleComponent = bundleIdentifier.flatMap { $0.isEmpty ? nil : $0 } ?? "cmux" + return ChatArtifactContentCache( + directory: caches + .appendingPathComponent(bundleComponent, isDirectory: true) + .appendingPathComponent("artifact-content", isDirectory: true), + maxDiskBytes: maxDiskBytes, + maxMemoryBytes: maxMemoryBytes + ) + } + + /// Derives a versioned key from authorization scope and host file metadata. + public static func key( + scopeKey: String, + path: String, + modifiedAt: Date?, + size: Int64? + ) -> String? { + guard let modifiedAt, let size else { return nil } + let raw = "\(scopeKey)#\(path)#\(String(modifiedAt.timeIntervalSince1970.bitPattern, radix: 16))#\(size)" + return SHA256.hash(data: Data(raw.utf8)).map { String(format: "%02x", $0) }.joined() + } + + /// Replays a cache hit or writes a fetched stream through atomically. + /// + /// - Returns: `true` when no source fetch was needed. + func stream( + for key: String, + expectedSize: Int64, + accessedAt: Date = Date(), + fetch: @escaping @Sendable ( + _ receive: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void, + receive: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Bool { + if let data = memoryCache.object(forKey: key as NSString) { + let value = Data(referencing: data) + try? touch(fileURL(for: key), at: accessedAt) + try await receive(Self.chunk(data: value, totalSize: expectedSize)) + return true + } + + if try await replayDiskEntry( + for: key, + expectedSize: expectedSize, + accessedAt: accessedAt, + receive: receive + ) { + return true + } + + let writer = try ChatArtifactContentCacheWriter( + directory: directory, + key: key, + expectedSize: expectedSize, + retainsMemoryCopy: expectedSize <= Int64(maxMemoryBytes) + ) + do { + try await fetch { chunk in + try Task.checkCancellation() + try await writer.append(chunk) + try await receive(chunk) + } + try Task.checkCancellation() + let data = try await writer.finish() + if let data, maxMemoryBytes > 0 { + memoryCache.setObject(data as NSData, forKey: key as NSString, cost: data.count) + } + try touch(fileURL(for: key), at: accessedAt) + try enforceDiskBudget() + return false + } catch { + await writer.discard() + throw error + } + } + + private func replayDiskEntry( + for key: String, + expectedSize: Int64, + accessedAt: Date, + receive: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Bool { + let url = fileURL(for: key) + guard let attributes = try? fileManager.attributesOfItem(atPath: url.path), + (attributes[.size] as? NSNumber)?.int64Value == expectedSize else { + try? fileManager.removeItem(at: url) + return false + } + try touch(url, at: accessedAt) + if expectedSize == 0 { + try await receive(Self.chunk(data: Data(), totalSize: 0)) + return true + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + var offset: Int64 = 0 + while offset < expectedSize { + try Task.checkCancellation() + let requested = Int(min(Int64(Self.diskReadChunkBytes), expectedSize - offset)) + guard let data = try handle.read(upToCount: requested), !data.isEmpty else { + throw CocoaError(.fileReadCorruptFile) + } + let chunkOffset = offset + offset += Int64(data.count) + try await receive(ChatArtifactChunk( + data: data, + offset: chunkOffset, + totalSize: expectedSize, + eof: offset == expectedSize + )) + } + return true + } + + private func fileURL(for key: String) -> URL { + directory.appendingPathComponent(key, isDirectory: false) + } + + private func touch(_ url: URL, at date: Date) throws { + try fileManager.setAttributes([.modificationDate: date], ofItemAtPath: url.path) + } + + private func enforceDiskBudget() throws { + let keys: Set = [ + .fileSizeKey, + .contentModificationDateKey, + .isRegularFileKey, + .nameKey, + ] + let urls = try fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles] + ) + var entries: [(url: URL, size: Int64, accessedAt: Date)] = [] + var totalBytes: Int64 = 0 + for url in urls { + let values = try url.resourceValues(forKeys: keys) + guard values.isRegularFile == true, + values.name?.hasSuffix(".partial") != true else { continue } + let size = Int64(values.fileSize ?? 0) + totalBytes += size + entries.append((url, size, values.contentModificationDate ?? .distantPast)) + } + guard totalBytes > maxDiskBytes else { return } + for entry in entries.sorted(by: { $0.accessedAt < $1.accessedAt }) + where totalBytes > maxDiskBytes { + try fileManager.removeItem(at: entry.url) + totalBytes -= entry.size + } + } + + private static func chunk(data: Data, totalSize: Int64) -> ChatArtifactChunk { + ChatArtifactChunk(data: data, offset: 0, totalSize: totalSize, eof: true) + } + + private static let diskReadChunkBytes = 1 * 1_024 * 1_024 +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift new file mode 100644 index 000000000000..10ce5f1aa29b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactContentCacheWriter.swift @@ -0,0 +1,79 @@ +import CmuxAgentChat +import Foundation + +/// Serializes one artifact stream into an atomic content-cache entry. +actor ChatArtifactContentCacheWriter { + private let expectedSize: Int64 + private let temporaryURL: URL + private let destinationURL: URL + private var fileHandle: FileHandle? + private var memoryData: Data? + private var nextOffset: Int64 = 0 + private var reachedEOF = false + + init( + directory: URL, + key: String, + expectedSize: Int64, + retainsMemoryCopy: Bool + ) throws { + let fileManager = FileManager() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + self.expectedSize = expectedSize + destinationURL = directory.appendingPathComponent(key, isDirectory: false) + temporaryURL = directory.appendingPathComponent( + ".\(key).\(UUID().uuidString).partial", + isDirectory: false + ) + guard fileManager.createFile(atPath: temporaryURL.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + do { + fileHandle = try FileHandle(forWritingTo: temporaryURL) + } catch { + try? fileManager.removeItem(at: temporaryURL) + throw error + } + memoryData = retainsMemoryCopy ? Data() : nil + if retainsMemoryCopy, expectedSize <= Int64(Int.max) { + memoryData?.reserveCapacity(Int(expectedSize)) + } + } + + func append(_ chunk: ChatArtifactChunk) throws { + guard !reachedEOF, + chunk.offset == nextOffset, + chunk.totalSize == expectedSize, + nextOffset <= expectedSize - Int64(chunk.data.count), + let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.write(contentsOf: chunk.data) + memoryData?.append(chunk.data) + nextOffset += Int64(chunk.data.count) + reachedEOF = chunk.eof + if reachedEOF, nextOffset != expectedSize { + throw ChatArtifactError.macUnreachable + } + } + + func finish() throws -> Data? { + guard reachedEOF, nextOffset == expectedSize, let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.close() + self.fileHandle = nil + let fileManager = FileManager() + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: temporaryURL, to: destinationURL) + return memoryData + } + + func discard() { + try? fileHandle?.close() + fileHandle = nil + try? FileManager().removeItem(at: temporaryURL) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactDataAccumulator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactDataAccumulator.swift new file mode 100644 index 000000000000..7e4952b9a3fa --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactDataAccumulator.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Accumulates non-text artifact chunks away from the main actor. +actor ChatArtifactDataAccumulator { + private var data = Data() + + func append(_ chunk: Data, totalSize: Int64) { + if data.isEmpty, totalSize > 0, totalSize <= Int64(Int.max) { + data.reserveCapacity(Int(totalSize)) + } + data.append(chunk) + } + + func value() -> Data { + data + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionPresentation.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionPresentation.swift new file mode 100644 index 000000000000..0cb420ff3358 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionPresentation.swift @@ -0,0 +1,127 @@ +import SwiftUI + +#if os(iOS) +import UIKit + +/// A system presentation prepared by an artifact file action. +public enum ChatArtifactFileActionPresentation: Identifiable, Equatable { + /// Presents the standard activity controller for a local file. + case share(URL) + /// Presents the Files document picker in export-as-copy mode. + case save(URL) + + /// Stable identity for SwiftUI sheet presentation. + public var id: String { + switch self { + case .share(let url): + return "share:\(url.path)" + case .save(let url): + return "save:\(url.path)" + } + } + + fileprivate var fileURL: URL { + switch self { + case .share(let url), .save(let url): + return url + } + } +} + +public extension View { + /// Presents Share or Save to Files for a materialized artifact. + /// + /// Temporary files are removed when the system controller finishes. + func chatArtifactFileActionPresentation( + _ presentation: Binding + ) -> some View { + modifier(ChatArtifactFileActionPresentationModifier(presentation: presentation)) + } +} + +private struct ChatArtifactFileActionPresentationModifier: ViewModifier { + @Binding var presentation: ChatArtifactFileActionPresentation? + + func body(content: Content) -> some View { + content.sheet(item: $presentation) { item in + switch item { + case .share(let url): + ChatArtifactActivityView(fileURL: url) { + finish(item) + } + case .save(let url): + ChatArtifactDocumentPicker(fileURL: url) { + finish(item) + } + } + } + } + + private func finish(_ item: ChatArtifactFileActionPresentation) { + presentation = nil + Task { + await ChatArtifactFileActionStore.applicationDefault.remove(item.fileURL) + } + } +} + +private struct ChatArtifactActivityView: UIViewControllerRepresentable { + let fileURL: URL + let onFinish: () -> Void + + func makeUIViewController(context: Context) -> UIActivityViewController { + let controller = UIActivityViewController( + activityItems: [fileURL], + applicationActivities: nil + ) + controller.completionWithItemsHandler = { _, _, _, _ in + onFinish() + } + return controller + } + + func updateUIViewController(_ controller: UIActivityViewController, context: Context) {} +} + +private struct ChatArtifactDocumentPicker: UIViewControllerRepresentable { + let fileURL: URL + let onFinish: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onFinish: onFinish) + } + + func makeUIViewController(context: Context) -> UIDocumentPickerViewController { + let controller = UIDocumentPickerViewController( + forExporting: [fileURL], + asCopy: true + ) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController( + _ controller: UIDocumentPickerViewController, + context: Context + ) {} + + final class Coordinator: NSObject, UIDocumentPickerDelegate { + private let onFinish: () -> Void + + init(onFinish: @escaping () -> Void) { + self.onFinish = onFinish + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + onFinish() + } + + func documentPicker( + _ controller: UIDocumentPickerViewController, + didPickDocumentsAt urls: [URL] + ) { + onFinish() + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionStore.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionStore.swift new file mode 100644 index 000000000000..26aa414d51f7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFileActionStore.swift @@ -0,0 +1,69 @@ +import CmuxAgentChat +import Foundation + +/// Materializes host artifacts as correctly named local files for system actions. +public actor ChatArtifactFileActionStore { + /// Shared purgeable store used by artifact viewers and gallery rows. + public static let applicationDefault = ChatArtifactFileActionStore() + + private let temporaryFileStore: ChatArtifactTemporaryFileStore + + /// Creates a file-action store rooted in a purgeable caches directory. + /// + /// - Parameter directory: Optional directory override used by tests. + public init(directory: URL? = nil) { + let resolvedDirectory: URL + if let directory { + resolvedDirectory = directory + } else { + let cachesDirectory = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + resolvedDirectory = cachesDirectory.appendingPathComponent( + "cmux-artifact-actions", + isDirectory: true + ) + } + temporaryFileStore = ChatArtifactTemporaryFileStore(directory: resolvedDirectory) + } + + /// Stats and streams one file through the shared content cache. + /// + /// The returned URL keeps the source basename so system share and document + /// pickers display the filename the user expects. + /// + /// - Parameters: + /// - path: Absolute path on the Mac host. + /// - loader: Scoped artifact loader authorizing the path. + /// - Returns: A local, correctly named temporary file. + public func materialize( + path: String, + loader: ChatArtifactLoader + ) async throws -> URL { + let stat = try await loader.stat(path: path) + guard !stat.isDirectory else { + throw ChatArtifactError.unsupported + } + let filename = Self.filename(for: path) + return try await temporaryFileStore.fetch( + path: path, + expectedSize: stat.size, + modifiedAt: stat.modifiedAt, + limit: Int64.max, + preferredFilename: filename, + loader: loader, + progress: { _ in } + ) + } + + /// Removes a file previously returned by ``materialize(path:loader:)``. + public func remove(_ fileURL: URL) async { + await temporaryFileStore.remove(fileURL) + } + + private static func filename(for path: String) -> String { + let candidate = URL(fileURLWithPath: path).lastPathComponent + return candidate.isEmpty ? "Artifact" : candidate + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderRoute.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderRoute.swift new file mode 100644 index 000000000000..a190282b3872 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderRoute.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Carries the original folder loader and scope into one lazily pushed child route. +struct ChatArtifactFolderRoute: Sendable { + let path: String + let scope: ChatArtifactViewerScope + let loader: ChatArtifactLoader + + init( + parentPath: String, + childName: String, + scope: ChatArtifactViewerScope, + loader: ChatArtifactLoader + ) { + path = (parentPath as NSString).appendingPathComponent(childName) + self.scope = scope + self.loader = loader + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift index 696ef86e628a..3045fc84301c 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactFolderView.swift @@ -9,33 +9,17 @@ import AppKit struct ChatArtifactFolderView: View { let path: String + let scope: ChatArtifactViewerScope + let onDone: () -> Void @Environment(\.chatArtifactLoader) private var loader - @Environment(\.dismiss) private var dismiss @State private var state: LoadState = .loading - @State private var selectedFile: ChatArtifactPathSelection? var body: some View { - NavigationStack { - content - .navigationTitle(displayName) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { - dismiss() - } - } - } - } - .task(id: path) { - await load() - } - .sheet(item: $selectedFile) { selection in - ChatArtifactViewerSheet(path: selection.path) - } + content + .task(id: path) { + await load() + } } @ViewBuilder @@ -44,15 +28,41 @@ struct ChatArtifactFolderView: View { case .loading: ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) - case .entries(let entries): - if entries.isEmpty { - Text(String(localized: "chat.artifact.folder.empty", defaultValue: "No files", bundle: .module)) - .font(.subheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - List(entries) { entry in - row(entry) + case .listing(let listing): + VStack(spacing: 0) { + breadcrumb + Divider() + if listing.entries.isEmpty { + Text(String(localized: "chat.artifact.folder.empty", defaultValue: "No items", bundle: .module)) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + Section { + ForEach(listing.entries) { entry in + NavigationLink { + let route = childRoute(for: entry) + ChatArtifactViewerDestination( + path: route.path, + scope: route.scope, + onDone: onDone + ) + .environment(\.chatArtifactLoader, route.loader) + } label: { + rowLabel(entry) + } + } + } footer: { + if listing.isTruncated { + Text(String( + localized: "chat.artifact.folder.showing_first_500", + defaultValue: "Showing first 500 items", + bundle: .module + )) + } + } + } } } case .failed: @@ -74,32 +84,33 @@ struct ChatArtifactFolderView: View { } } - private func row(_ entry: ChatArtifactDirectoryEntry) -> some View { - Button { - guard !entry.isDirectory else { return } - selectedFile = ChatArtifactPathSelection(path: childPath(named: entry.name)) - } label: { - HStack(spacing: 10) { - ChatArtifactFolderThumbnail(path: childPath(named: entry.name), entry: entry) - VStack(alignment: .leading, spacing: 2) { - Text(entry.name) - .lineLimit(1) - .truncationMode(.middle) - if !entry.isDirectory { - Text(ByteCountFormatter.string(fromByteCount: entry.size, countStyle: .file)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - Spacer(minLength: 8) - if entry.isDirectory { - Image(systemName: "folder") + private var breadcrumb: some View { + Text(parentPath) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .accessibilityLabel(Text(verbatim: path)) + } + + private func rowLabel(_ entry: ChatArtifactDirectoryEntry) -> some View { + HStack(spacing: 10) { + ChatArtifactFolderThumbnail(path: childPath(named: entry.name), entry: entry) + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .lineLimit(1) + .truncationMode(.middle) + if !entry.isDirectory { + Text(ByteCountFormatter.string(fromByteCount: entry.size, countStyle: .file)) + .font(.caption) .foregroundStyle(.secondary) } } + Spacer(minLength: 8) } - .buttonStyle(.plain) - .disabled(entry.isDirectory) } private func load() async { @@ -107,7 +118,7 @@ struct ChatArtifactFolderView: View { do { let listing = try await loader.list(path: path) guard !Task.isCancelled else { return } - await MainActor.run { state = .entries(listing.entries) } + await MainActor.run { state = .listing(listing) } } catch { await MainActor.run { state = .failed } } @@ -117,13 +128,24 @@ struct ChatArtifactFolderView: View { (path as NSString).appendingPathComponent(name) } - private var displayName: String { - URL(fileURLWithPath: path).lastPathComponent + private func childRoute(for entry: ChatArtifactDirectoryEntry) -> ChatArtifactFolderRoute { + ChatArtifactFolderRoute( + parentPath: path, + childName: entry.name, + scope: scope, + loader: loader + ) + } + + private var parentPath: String { + guard path != "/" else { return "/" } + let parent = (path as NSString).deletingLastPathComponent + return parent.isEmpty ? "/" : parent } private enum LoadState: Equatable { case loading - case entries([ChatArtifactDirectoryEntry]) + case listing(ChatArtifactDirectoryListing) case failed } } @@ -141,9 +163,7 @@ private struct ChatArtifactFolderThumbnail: View { artifactImage(data: thumbnailData) .scaledToFill() } else { - Image(systemName: entry.isDirectory ? "folder" : iconName) - .font(.body) - .foregroundStyle(.secondary) + placeholder } } .frame(width: 34, height: 34) @@ -161,29 +181,28 @@ private struct ChatArtifactFolderThumbnail: View { if let image = UIImage(data: data) { Image(uiImage: image).resizable() } else { - Image(systemName: iconName) + placeholder } #elseif canImport(AppKit) if let image = NSImage(data: data) { Image(nsImage: image).resizable() } else { - Image(systemName: iconName) + placeholder } #else - Image(systemName: iconName) + placeholder #endif } - private var iconName: String { - switch entry.kind { - case .image: - return "photo" - case .text: - return "doc.text" - case .binary: - return "doc" - case .directory: - return "folder" - } + private var placeholder: some View { + let kind: ChatArtifactKind = entry.isDirectory ? .directory : entry.kind + let glyph = ChatArtifactGalleryClassifier().glyphPresentation( + for: kind, + path: path + ) + return Image(systemName: glyph.systemImageName) + .font(.body) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(glyph.tint.swiftUIColor) } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGalleryGlyphTint+SwiftUI.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGalleryGlyphTint+SwiftUI.swift new file mode 100644 index 000000000000..0356d7300d4f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGalleryGlyphTint+SwiftUI.swift @@ -0,0 +1,14 @@ +import CmuxAgentChat +import SwiftUI + +public extension ChatArtifactGalleryGlyphTint { + /// SwiftUI color corresponding to the shared gallery tint. + var swiftUIColor: Color { + switch self { + case .accent: + return .blue + case .secondary: + return .secondary + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGoToLineBar.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGoToLineBar.swift new file mode 100644 index 000000000000..9956bb959a74 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactGoToLineBar.swift @@ -0,0 +1,82 @@ +import SwiftUI + +/// Inline numeric controls for jumping to a currently loaded text line. +struct ChatArtifactGoToLineBar: View { + @Binding var lineText: String + let onGo: (Int) -> Void + let onClose: () -> Void + + @FocusState private var isLineFieldFocused: Bool + + var body: some View { + HStack(spacing: 8) { + lineField + + Button { + submit() + } label: { + Text(String( + localized: "chat.artifact.line.go", + defaultValue: "Go", + bundle: .module + )) + } + .buttonStyle(.borderedProminent) + .disabled(Int(lineText) == nil) + + Button { + isLineFieldFocused = false + onClose() + } label: { + Image(systemName: "xmark.circle.fill") + } + .buttonStyle(.plain) + .accessibilityLabel(String( + localized: "chat.artifact.line.close", + defaultValue: "Close go to line", + bundle: .module + )) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + .onAppear { + isLineFieldFocused = true + } + .onDisappear { + isLineFieldFocused = false + } + } + + @ViewBuilder + private var lineField: some View { + #if os(iOS) + baseLineField + .keyboardType(.numberPad) + #else + baseLineField + #endif + } + + private var baseLineField: some View { + TextField( + String( + localized: "chat.artifact.line.placeholder", + defaultValue: "Line number", + bundle: .module + ), + text: $lineText + ) + .textFieldStyle(.roundedBorder) + .focused($isLineFieldFocused) + .onSubmit { + submit() + } + } + + private func submit() { + guard let line = Int(lineText) else { return } + isLineFieldFocused = false + onGo(line) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightDecision.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightDecision.swift new file mode 100644 index 000000000000..b34cfc8ea843 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightDecision.swift @@ -0,0 +1,11 @@ +/// Describes whether and how a loaded text artifact should be syntax highlighted. +enum ChatArtifactHighlightDecision: Equatable, Sendable { + case highlight(language: String?) + case skippedForSize + case skippedNoLanguage + + /// Whether the size-threshold explanation belongs in the viewer chrome. + var showsHighlightingOffPill: Bool { + self == .skippedForSize + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightTheme.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightTheme.swift new file mode 100644 index 000000000000..d9e2838a714c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightTheme.swift @@ -0,0 +1,5 @@ +/// The syntax color palette selected from the viewer's current appearance. +enum ChatArtifactHighlightTheme: Equatable, Sendable { + case light + case dark +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightedText.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightedText.swift new file mode 100644 index 000000000000..ea8655dff8a6 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightedText.swift @@ -0,0 +1,13 @@ +import Foundation + +/// An immutable attributed-string snapshot produced by the background highlighter. +/// +/// SAFETY: `value` is copied into an immutable `NSAttributedString` before transfer +/// and is only read after the producing actor has returned it. +final class ChatArtifactHighlightedText: @unchecked Sendable { + let value: NSAttributedString + + init(_ value: NSAttributedString) { + self.value = NSAttributedString(attributedString: value) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift new file mode 100644 index 000000000000..ca4f847b7001 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactHighlightingStatusPill.swift @@ -0,0 +1,62 @@ +import Foundation +import SwiftUI + +/// A compact, expandable explanation for syntax highlighting disabled by file size. +struct ChatArtifactHighlightingStatusPill: View { + let actualBytes: Int64 + let maximumBytes: Int64 + + @State private var isExpanded = false + + var body: some View { + Button { + withAnimation(.snappy) { + isExpanded.toggle() + } + } label: { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "paintbrush.slash") + .imageScale(.small) + if isExpanded { + Text(explanation) + .font(.caption) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + } else { + Text(String( + localized: "chat.artifact.highlighting.off", + defaultValue: "Highlighting off", + bundle: .module + )) + .font(.caption.weight(.semibold)) + .transition(.opacity) + } + Image(systemName: isExpanded ? "xmark.circle.fill" : "chevron.right") + .imageScale(.small) + .foregroundStyle(.secondary) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: isExpanded ? 360 : nil, alignment: .leading) + .background(Color.secondary.opacity(0.12), in: Capsule()) + .clipShape(Capsule()) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var explanation: String { + let format = String( + localized: "chat.artifact.highlighting.off.explanation", + defaultValue: "This file is %@. Syntax highlighting is off above %@ to keep scrolling smooth.", + bundle: .module + ) + return String.localizedStringWithFormat( + format, + ByteCountFormatter.string(fromByteCount: actualBytes, countStyle: .file), + ByteCountFormatter.string(fromByteCount: maximumBytes, countStyle: .file) + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift new file mode 100644 index 000000000000..5970f856e5d4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndex.swift @@ -0,0 +1,52 @@ +/// Incremental UTF-16 line starts for streamed text and constant-time line jumps. +struct ChatArtifactLineIndex: Equatable, Sendable { + private(set) var lineStartOffsets: [Int] = [0] + private(set) var loadedUTF16Length = 0 + + var lineCount: Int { + lineStartOffsets.count + } + + /// Adds line starts from one newly decoded streaming chunk. + mutating func append(_ text: String) { + append(ChatArtifactLineIndexBatch(text: text)) + } + + /// Applies a batch whose UTF-16 scan may have been computed off-main. + mutating func append(_ batch: ChatArtifactLineIndexBatch) { + let baseOffset = loadedUTF16Length + lineStartOffsets.reserveCapacity( + lineStartOffsets.count + batch.relativeLineStartOffsets.count + ) + for relativeOffset in batch.relativeLineStartOffsets { + lineStartOffsets.append(baseOffset + relativeOffset) + } + loadedUTF16Length += batch.utf16Length + } + + /// Clamps a one-based requested line to the range currently loaded. + func clampedLine(_ requestedLine: Int) -> Int { + min(max(requestedLine, 1), lineCount) + } + + /// Returns the UTF-16 location for a one-based line, clamped to loaded lines. + func offset(forLine requestedLine: Int) -> Int { + lineStartOffsets[clampedLine(requestedLine) - 1] + } + + /// Finds the one-based logical line containing a UTF-16 text location. + func lineNumber(containingUTF16Offset requestedOffset: Int) -> Int { + let offset = min(max(requestedOffset, 0), loadedUTF16Length) + var lowerBound = 0 + var upperBound = lineStartOffsets.count + while lowerBound < upperBound { + let midpoint = (lowerBound + upperBound) / 2 + if lineStartOffsets[midpoint] <= offset { + lowerBound = midpoint + 1 + } else { + upperBound = midpoint + } + } + return max(lowerBound, 1) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndexBatch.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndexBatch.swift new file mode 100644 index 000000000000..6713664fd925 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineIndexBatch.swift @@ -0,0 +1,18 @@ +/// Precomputed line-index contribution for one streamed text chunk. +struct ChatArtifactLineIndexBatch: Equatable, Sendable { + let utf16Length: Int + let relativeLineStartOffsets: [Int] + + init(text: String) { + var utf16Length = 0 + var lineStartOffsets: [Int] = [] + for codeUnit in text.utf16 { + utf16Length += 1 + if codeUnit == 0x0A { + lineStartOffsets.append(utf16Length) + } + } + self.utf16Length = utf16Length + relativeLineStartOffsets = lineStartOffsets + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineNumberGutterView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineNumberGutterView.swift new file mode 100644 index 000000000000..61e1c47121b5 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLineNumberGutterView.swift @@ -0,0 +1,74 @@ +#if canImport(UIKit) +import UIKit + +/// Draws only line numbers whose TextKit 1 fragments intersect the viewport. +@MainActor +final class ChatArtifactLineNumberGutterView: UIView { + weak var textView: UITextView? + var lineIndex = ChatArtifactLineIndex() + var textFontPointSize: CGFloat = UIFont.preferredFont(forTextStyle: .body).pointSize + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.45) + isOpaque = false + contentMode = .redraw + } + + required init?(coder: NSCoder) { + nil + } + + override func draw(_ rect: CGRect) { + guard let textView else { return } + let textContainer = textView.textContainer + let layoutManager = textView.layoutManager + let visibleTextRect = CGRect( + x: textView.contentOffset.x - textView.textContainerInset.left, + y: textView.contentOffset.y - textView.textContainerInset.top, + width: textView.bounds.width, + height: textView.bounds.height + ) + let glyphRange = layoutManager.glyphRange( + forBoundingRect: visibleTextRect, + in: textContainer + ) + let font = UIFont.monospacedDigitSystemFont( + ofSize: max(9, textFontPointSize * 0.78), + weight: .regular + ) + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.alignment = .right + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor.secondaryLabel, + .paragraphStyle: paragraphStyle, + ] + + layoutManager.enumerateLineFragments(forGlyphRange: glyphRange) { + [weak self] _, usedRect, _, fragmentGlyphRange, _ in + guard let self else { return } + let characterRange = layoutManager.characterRange( + forGlyphRange: fragmentGlyphRange, + actualGlyphRange: nil + ) + let lineNumber = self.lineIndex.lineNumber( + containingUTF16Offset: characterRange.location + ) + guard self.lineIndex.offset(forLine: lineNumber) == characterRange.location else { + return + } + let labelRect = CGRect( + x: 4, + y: usedRect.minY + textView.textContainerInset.top - textView.contentOffset.y, + width: max(0, self.bounds.width - 12), + height: max(usedRect.height, font.lineHeight) + ) + String(lineNumber).draw(in: labelRect, withAttributes: attributes) + } + + UIColor.separator.setFill() + UIBezierPath(rect: CGRect(x: bounds.maxX - 0.5, y: 0, width: 0.5, height: bounds.height)).fill() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift index 8a4e6347ffad..35f3b0aa29d2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactLoader.swift @@ -77,6 +77,9 @@ public enum ChatArtifactLoaderScope: Hashable, Sendable { /// Value-type closure bundle for Mac-hosted artifact operations. public struct ChatArtifactLoader: Sendable { public let supportsArtifacts: Bool + /// Whether directory stat results may route into a navigable folder browser. + public let supportsDirectoryBrowsing: Bool + /// Authorization and cache namespace for artifact operations. public let scope: ChatArtifactLoaderScope private let statHandler: @Sendable (_ path: String) async throws -> ChatArtifactStat @@ -84,14 +87,35 @@ public struct ChatArtifactLoader: Sendable { _ path: String, _ progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data + private let streamHandler: @Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void private let thumbnailHandler: @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail private let listHandler: @Sendable (_ path: String) async throws -> ChatArtifactDirectoryListing private let thumbnailCache: ChatArtifactThumbnailCache + private let contentCache: ChatArtifactContentCache + /// Creates a closure-backed artifact loader. + /// + /// - Parameters: + /// - supportsArtifacts: Whether artifact operations are available. + /// - supportsDirectoryBrowsing: Whether directory stat results may be listed. + /// - scope: Cache and authorization namespace for this loader. + /// - cache: Thumbnail cache shared by rows and viewers. + /// - contentCache: Full-content cache shared by viewer routes. + /// - stat: Metadata operation for an absolute host path. + /// - fetch: Whole-file operation retained for image and compatibility callers. + /// - stream: Optional structured chunk operation; defaults to one callback + /// containing the result of `fetch`. + /// - thumbnail: Thumbnail operation for image artifacts. + /// - list: Immediate-directory listing operation. public init( supportsArtifacts: Bool = false, + supportsDirectoryBrowsing: Bool = false, scope: ChatArtifactLoaderScope = .unsupported, cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault(), stat: @escaping @Sendable (_ path: String) async throws -> ChatArtifactStat = { _ in throw ChatArtifactError.unsupported }, @@ -101,6 +125,10 @@ public struct ChatArtifactLoader: Sendable { ) async throws -> Data = { _, _ in throw ChatArtifactError.unsupported }, + stream: (@Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void)? = nil, thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail = { _, _ in throw ChatArtifactError.unsupported }, @@ -109,10 +137,24 @@ public struct ChatArtifactLoader: Sendable { } ) { self.supportsArtifacts = supportsArtifacts + self.supportsDirectoryBrowsing = supportsDirectoryBrowsing self.scope = scope self.thumbnailCache = cache + self.contentCache = contentCache statHandler = stat fetchHandler = fetch + streamHandler = stream ?? { path, onChunk in + let data = try await fetch(path, nil) + try Task.checkCancellation() + try await onChunk( + ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + ) + ) + } thumbnailHandler = thumbnail listHandler = list } @@ -120,18 +162,24 @@ public struct ChatArtifactLoader: Sendable { public init( source: any ChatEventSource, sessionID: String, - cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache() + cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault() ) { self.init( supportsArtifacts: source.supportsArtifacts, + supportsDirectoryBrowsing: source.supportsArtifactFolders, scope: .chat(sessionID: sessionID), cache: cache, + contentCache: contentCache, stat: { path in try await source.artifactStat(sessionID: sessionID, path: path) }, fetch: { path, progress in try await source.artifactFetch(sessionID: sessionID, path: path, progress: progress) }, + stream: { path, onChunk in + try await source.artifactFetch(sessionID: sessionID, path: path, onChunk: onChunk) + }, thumbnail: { path, maxDimension in try await source.artifactThumbnail( sessionID: sessionID, @@ -145,31 +193,60 @@ public struct ChatArtifactLoader: Sendable { ) } + /// Creates a terminal-scoped closure-backed artifact loader. + /// + /// - Parameters: + /// - terminalWorkspaceID: Workspace containing the terminal surface. + /// - terminalSurfaceID: Terminal surface authorizing visible paths. + /// - supportsArtifacts: Whether terminal artifact operations are available. + /// - supportsDirectoryBrowsing: Whether terminal directory listing is available. + /// - cache: Thumbnail cache shared by rows and viewers. + /// - contentCache: Full-content cache shared by viewer routes. + /// - stat: Metadata operation for an absolute host path. + /// - fetch: Whole-file compatibility operation. + /// - stream: Optional structured chunk operation. + /// - thumbnail: Thumbnail operation for image artifacts. + /// - list: Immediate-directory listing operation. public init( terminalWorkspaceID: String, terminalSurfaceID: String, supportsArtifacts: Bool, + supportsDirectoryBrowsing: Bool = false, cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault(), stat: @escaping @Sendable (_ path: String) async throws -> ChatArtifactStat, fetch: @escaping @Sendable ( _ path: String, _ progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data, - thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail + stream: (@Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void)? = nil, + thumbnail: @escaping @Sendable (_ path: String, _ maxDimension: Int) async throws -> ChatArtifactThumbnail, + list: @escaping @Sendable (_ path: String) async throws -> ChatArtifactDirectoryListing = { _ in + throw ChatArtifactError.unsupported + } ) { self.init( supportsArtifacts: supportsArtifacts, + supportsDirectoryBrowsing: supportsDirectoryBrowsing, scope: .terminal(workspaceID: terminalWorkspaceID, surfaceID: terminalSurfaceID), cache: cache, + contentCache: contentCache, stat: stat, fetch: fetch, + stream: stream, thumbnail: thumbnail, - list: { _ in throw ChatArtifactError.unsupported } + list: list ) } - public static func unsupported(cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache()) -> ChatArtifactLoader { - ChatArtifactLoader(cache: cache) + public static func unsupported( + cache: ChatArtifactThumbnailCache = ChatArtifactThumbnailCache(), + contentCache: ChatArtifactContentCache = .applicationDefault() + ) -> ChatArtifactLoader { + ChatArtifactLoader(cache: cache, contentCache: contentCache) } public func stat(path: String) async throws -> ChatArtifactStat { @@ -183,6 +260,40 @@ public struct ChatArtifactLoader: Sendable { try await fetchHandler(path, progress) } + /// Streams artifact chunks without requiring a contiguous whole-file copy. + /// + /// - Parameters: + /// - path: Absolute Mac host path. + /// - modifiedAt: Stat modification time used to invalidate cached bytes. + /// - size: Stat byte size used to validate and invalidate cached bytes. + /// - onChunk: Structured callback awaited for each chunk in byte order. + public func stream( + path: String, + modifiedAt: Date? = nil, + size: Int64? = nil, + onChunk: @escaping @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + guard scope != .unsupported, + let key = ChatArtifactContentCache.key( + scopeKey: scope.cacheNamespace, + path: path, + modifiedAt: modifiedAt, + size: size + ), let size else { + try await streamHandler(path, onChunk) + return + } + let handler = streamHandler + _ = try await contentCache.stream( + for: key, + expectedSize: size, + fetch: { receive in + try await handler(path, receive) + }, + receive: onChunk + ) + } + public func thumbnail( path: String, maxDimension: Int, @@ -209,7 +320,10 @@ public struct ChatArtifactLoader: Sendable { } public func list(path: String) async throws -> ChatArtifactDirectoryListing { - try await listHandler(path) + guard supportsDirectoryBrowsing else { + throw ChatArtifactError.unsupported + } + return try await listHandler(path) } private func thumbnailCacheKey( diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownBlock.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownBlock.swift new file mode 100644 index 000000000000..14a8f924947c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownBlock.swift @@ -0,0 +1,19 @@ +/// One document-level Markdown block with structure preserved for layout. +struct ChatArtifactMarkdownBlock: Identifiable, Equatable, Sendable { + enum Kind: Equatable, Sendable { + case heading(level: Int) + case paragraph + case bullet(indent: Int) + case ordered(marker: String, indent: Int) + case quote + case rule + case code(language: String?) + case tableRow(isHeader: Bool) + } + + let index: Int + let kind: Kind + let text: String + + var id: Int { index } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownDocument.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownDocument.swift new file mode 100644 index 000000000000..92214a144a47 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownDocument.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Parses Markdown into stable block-level values before SwiftUI lays it out. +struct ChatArtifactMarkdownDocument: Equatable, Sendable { + let blocks: [ChatArtifactMarkdownBlock] + + init(markdown: String) { + var parsed: [ChatArtifactMarkdownBlock] = [] + for segment in ChatProseSegmenter().segments(from: markdown) { + switch segment.kind { + case .text: + for textBlock in ChatTextBlockParser().blocks(from: segment.content) { + parsed.append(contentsOf: Self.blocks(from: textBlock)) + } + case .code(let language): + parsed.append(ChatArtifactMarkdownBlock( + index: parsed.count, + kind: .code(language: language), + text: segment.content + )) + } + } + blocks = parsed.enumerated().map { index, block in + ChatArtifactMarkdownBlock(index: index, kind: block.kind, text: block.text) + } + } + + private static func blocks(from block: ChatTextBlock) -> [ChatArtifactMarkdownBlock] { + switch block.kind { + case .heading(let level): + return [markdownBlock(kind: .heading(level: level), text: block.text)] + case .paragraph: + return paragraphBlocks(from: block.text) + case .bullet(let indent): + return [markdownBlock(kind: .bullet(indent: indent), text: block.text)] + case .ordered(let marker, let indent): + return [markdownBlock(kind: .ordered(marker: marker, indent: indent), text: block.text)] + case .quote: + return [markdownBlock(kind: .quote, text: block.text)] + case .rule: + return [markdownBlock(kind: .rule, text: "")] + } + } + + /// Splits GitHub-style tables out of paragraph runs while preserving surrounding prose. + private static func paragraphBlocks(from text: String) -> [ChatArtifactMarkdownBlock] { + let lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var result: [ChatArtifactMarkdownBlock] = [] + var paragraphLines: [String] = [] + var index = 0 + + while index < lines.count { + let startsTable = index + 1 < lines.count + && tableCells(in: lines[index]) != nil + && isTableSeparator(lines[index + 1]) + if !startsTable { + paragraphLines.append(lines[index]) + index += 1 + continue + } + + if !paragraphLines.isEmpty { + result.append(markdownBlock( + kind: .paragraph, + text: paragraphLines.joined(separator: "\n") + )) + paragraphLines.removeAll(keepingCapacity: true) + } + + if let headerCells = tableCells(in: lines[index]) { + result.append(markdownBlock( + kind: .tableRow(isHeader: true), + text: headerCells.joined(separator: " │ ") + )) + } + index += 2 + while index < lines.count, let cells = tableCells(in: lines[index]) { + result.append(markdownBlock( + kind: .tableRow(isHeader: false), + text: cells.joined(separator: " │ ") + )) + index += 1 + } + } + + if !paragraphLines.isEmpty { + result.append(markdownBlock( + kind: .paragraph, + text: paragraphLines.joined(separator: "\n") + )) + } + return result + } + + private static func tableCells(in line: String) -> [String]? { + let cells = line + .split(separator: "|", omittingEmptySubsequences: true) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard cells.count >= 2, cells.allSatisfy({ !$0.isEmpty }) else { return nil } + return cells + } + + private static func isTableSeparator(_ line: String) -> Bool { + guard let cells = tableCells(in: line) else { return false } + return cells.allSatisfy { cell in + let withoutColons = cell.trimmingCharacters(in: CharacterSet(charactersIn: ":")) + return withoutColons.count >= 3 && withoutColons.allSatisfy { $0 == "-" } + } + } + + private static func markdownBlock( + kind: ChatArtifactMarkdownBlock.Kind, + text: String + ) -> ChatArtifactMarkdownBlock { + ChatArtifactMarkdownBlock(index: 0, kind: kind, text: text) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownMode.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownMode.swift new file mode 100644 index 000000000000..4604f11b5c1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownMode.swift @@ -0,0 +1,5 @@ +/// User-selectable Markdown presentation modes. +enum ChatArtifactMarkdownMode: Hashable, Sendable { + case raw + case rendered +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownPresentation.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownPresentation.swift new file mode 100644 index 000000000000..98b38459ae1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownPresentation.swift @@ -0,0 +1,20 @@ +/// Enforces the rendered-Markdown size threshold and selected mode. +struct ChatArtifactMarkdownPresentation: Equatable, Sendable { + static let maximumRenderedByteCount: Int64 = 1_500_000 + + let isRenderedAvailable: Bool + private(set) var mode: ChatArtifactMarkdownMode + + init(byteCount: Int64) { + isRenderedAvailable = byteCount <= Self.maximumRenderedByteCount + mode = isRenderedAvailable ? .rendered : .raw + } + + mutating func select(_ requestedMode: ChatArtifactMarkdownMode) { + if requestedMode == .rendered, !isRenderedAvailable { + mode = .raw + } else { + mode = requestedMode + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift new file mode 100644 index 000000000000..1769a288efc1 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMarkdownView.swift @@ -0,0 +1,102 @@ +import Foundation +import SwiftUI + +/// Renders document-level Markdown with Foundation's native syntax support. +struct ChatArtifactMarkdownView: View { + let markdown: String + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(ChatArtifactMarkdownDocument(markdown: markdown).blocks) { block in + blockView(block) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + } + + @ViewBuilder + private func blockView(_ block: ChatArtifactMarkdownBlock) -> some View { + switch block.kind { + case .heading(let level): + Text(renderedInline(block.text)) + .font(headingFont(level: level)) + .textSelection(.enabled) + .padding(.top, block.index == 0 ? 0 : 4) + case .paragraph: + Text(renderedInline(block.text)) + .font(.body) + .textSelection(.enabled) + case .bullet(let indent): + listRow(marker: "•", text: block.text, indent: indent) + case .ordered(let marker, let indent): + listRow(marker: marker, text: block.text, indent: indent) + case .quote: + HStack(spacing: 10) { + Rectangle() + .fill(.tertiary) + .frame(width: 3) + Text(renderedInline(block.text)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + case .rule: + Divider() + .padding(.vertical, 4) + case .code(let language): + VStack(alignment: .leading, spacing: 6) { + if let language, !language.isEmpty { + Text(verbatim: language.uppercased()) + .font(.caption2.weight(.semibold).monospaced()) + .foregroundStyle(.secondary) + } + ScrollView(.horizontal) { + Text(verbatim: block.text.isEmpty ? " " : block.text) + .font(.body.monospaced()) + .textSelection(.enabled) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.secondary.opacity(0.1), in: .rect(cornerRadius: 8)) + case .tableRow(let isHeader): + ScrollView(.horizontal) { + Text(verbatim: block.text) + .font(.system(.body, design: .monospaced).weight(isHeader ? .semibold : .regular)) + .textSelection(.enabled) + } + .padding(.vertical, isHeader ? 4 : 0) + } + } + + private func listRow(marker: String, text: String, indent: Int) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(verbatim: marker) + .foregroundStyle(.secondary) + .frame(minWidth: 20, alignment: .trailing) + Text(renderedInline(text)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .padding(.leading, CGFloat(min(indent, 4)) * 16) + } + + private func headingFont(level: Int) -> Font { + switch level { + case 1: .title.weight(.bold) + case 2: .title2.weight(.bold) + case 3: .title3.weight(.semibold) + default: .headline + } + } + + private func renderedInline(_ markdown: String) -> AttributedString { + var options = AttributedString.MarkdownParsingOptions() + options.interpretedSyntax = .inlineOnlyPreservingWhitespace + options.failurePolicy = .returnPartiallyParsedIfPossible + return (try? AttributedString(markdown: markdown, options: options)) + ?? AttributedString(markdown) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMediaView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMediaView.swift new file mode 100644 index 000000000000..b0ad044323e4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactMediaView.swift @@ -0,0 +1,32 @@ +#if os(iOS) +import AVKit +import SwiftUI + +/// Hosts AVKit's system playback controls for a local movie or audio artifact. +struct ChatArtifactMediaView: UIViewControllerRepresentable { + let fileURL: URL + + func makeUIViewController(context: Context) -> AVPlayerViewController { + let controller = AVPlayerViewController() + controller.player = AVPlayer(url: fileURL) + controller.videoGravity = .resizeAspect + return controller + } + + func updateUIViewController(_ controller: AVPlayerViewController, context: Context) { + let currentURL = (controller.player?.currentItem?.asset as? AVURLAsset)?.url + if currentURL != fileURL { + controller.player?.pause() + controller.player = AVPlayer(url: fileURL) + } + } + + static func dismantleUIViewController( + _ controller: AVPlayerViewController, + coordinator: Void + ) { + controller.player?.pause() + controller.player = nil + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPDFView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPDFView.swift new file mode 100644 index 000000000000..e510a8bc3b97 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPDFView.swift @@ -0,0 +1,25 @@ +#if os(iOS) +import PDFKit +import SwiftUI + +/// Hosts PDFKit's zoomable, scrollable document viewer for a local artifact. +struct ChatArtifactPDFView: UIViewRepresentable { + let fileURL: URL + + func makeUIView(context: Context) -> PDFView { + let pdfView = PDFView() + pdfView.autoScales = true + pdfView.displayMode = .singlePageContinuous + pdfView.displayDirection = .vertical + pdfView.document = PDFDocument(url: fileURL) + return pdfView + } + + func updateUIView(_ pdfView: PDFView, context: Context) { + if pdfView.document?.documentURL != fileURL { + pdfView.document = PDFDocument(url: fileURL) + } + pdfView.autoScales = true + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageControllerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageControllerState.swift new file mode 100644 index 000000000000..11c0d7944e21 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageControllerState.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Pure ordering and completed-selection state for the UIKit artifact pager. +struct ChatArtifactPageControllerState: Equatable, Sendable { + private(set) var paths: [String] + private(set) var selectedPath: String + + init(paths: [String], selectedPath: String) { + self.paths = Self.unique(paths) + self.selectedPath = selectedPath + normalizeSelection() + } + + @discardableResult + mutating func update(paths: [String], selectedPath: String) -> Bool { + let uniquePaths = Self.unique(paths) + let didChangePaths = self.paths != uniquePaths + self.paths = uniquePaths + self.selectedPath = selectedPath + normalizeSelection() + return didChangePaths + } + + mutating func completeTransition(to path: String) -> Bool { + guard paths.contains(path), path != selectedPath else { return false } + selectedPath = path + return true + } + + func path(before path: String) -> String? { + guard let index = paths.firstIndex(of: path), index > paths.startIndex else { + return nil + } + return paths[paths.index(before: index)] + } + + func path(after path: String) -> String? { + guard let index = paths.firstIndex(of: path), + paths.index(after: index) < paths.endIndex else { + return nil + } + return paths[paths.index(after: index)] + } + + func isForwardTransition(from source: String?, to destination: String) -> Bool { + guard let source, + let sourceIndex = paths.firstIndex(of: source), + let destinationIndex = paths.firstIndex(of: destination) else { + return true + } + return destinationIndex >= sourceIndex + } + + private mutating func normalizeSelection() { + if !paths.contains(selectedPath), let firstPath = paths.first { + selectedPath = firstPath + } + } + + private static func unique(_ paths: [String]) -> [String] { + var seen: Set = [] + return paths.filter { seen.insert($0).inserted } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewController.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewController.swift new file mode 100644 index 000000000000..be3faa021601 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewController.swift @@ -0,0 +1,47 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// Bridges path-stable artifact hosts into UIKit's transactional page controller. +struct ChatArtifactPageViewController: UIViewControllerRepresentable { + let pages: [ChatArtifactViewerHostedPage] + @Binding var selectedPath: String + let isPagingEnabled: Bool + + func makeCoordinator() -> ChatArtifactPageViewControllerCoordinator { + ChatArtifactPageViewControllerCoordinator() + } + + func makeUIViewController(context: Context) -> UIPageViewController { + let controller = UIPageViewController( + transitionStyle: .scroll, + navigationOrientation: .horizontal + ) + controller.view.backgroundColor = .systemBackground + controller.view.isOpaque = true + controller.view.clipsToBounds = true + controller.dataSource = context.coordinator + controller.delegate = context.coordinator + context.coordinator.attach(controller) + context.coordinator.update( + pages: pages, + selectedPath: selectedPath, + selection: $selectedPath, + isPagingEnabled: isPagingEnabled + ) + return controller + } + + func updateUIViewController( + _ controller: UIPageViewController, + context: Context + ) { + context.coordinator.update( + pages: pages, + selectedPath: selectedPath, + selection: $selectedPath, + isPagingEnabled: isPagingEnabled + ) + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewControllerCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewControllerCoordinator.swift new file mode 100644 index 000000000000..8819498429cf --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPageViewControllerCoordinator.swift @@ -0,0 +1,147 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// Owns one clipped hosting controller per path and commits only completed page transitions. +@MainActor +final class ChatArtifactPageViewControllerCoordinator: NSObject, + UIPageViewControllerDataSource, + UIPageViewControllerDelegate +{ + private weak var pageController: UIPageViewController? + private var state = ChatArtifactPageControllerState(paths: [], selectedPath: "") + private var pagesByPath: [String: ChatArtifactViewerHostedPage] = [:] + private var hostsByPath: [String: UIHostingController] = [:] + private var selection: Binding? + private var isTransitioning = false + private var needsDataSourceReload = false + + func attach(_ controller: UIPageViewController) { + pageController = controller + } + + func update( + pages: [ChatArtifactViewerHostedPage], + selectedPath: String, + selection: Binding, + isPagingEnabled: Bool + ) { + self.selection = selection + pagesByPath = Dictionary(uniqueKeysWithValues: pages.map { ($0.path, $0) }) + for page in pages { + hostsByPath[page.path]?.rootView = page + } + needsDataSourceReload = state.update( + paths: pages.map(\.path), + selectedPath: selectedPath + ) || needsDataSourceReload + configurePaging(isEnabled: isPagingEnabled) + guard !isTransitioning else { return } + removeUnusedHosts() + reloadDataSourceIfNeeded() + synchronizeDisplayedPage() + } + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerBefore viewController: UIViewController + ) -> UIViewController? { + guard let path = path(for: viewController), + let previousPath = state.path(before: path) else { + return nil + } + return host(for: previousPath) + } + + func pageViewController( + _ pageViewController: UIPageViewController, + viewControllerAfter viewController: UIViewController + ) -> UIViewController? { + guard let path = path(for: viewController), + let nextPath = state.path(after: path) else { + return nil + } + return host(for: nextPath) + } + + func pageViewController( + _ pageViewController: UIPageViewController, + willTransitionTo pendingViewControllers: [UIViewController] + ) { + isTransitioning = true + } + + func pageViewController( + _ pageViewController: UIPageViewController, + didFinishAnimating finished: Bool, + previousViewControllers: [UIViewController], + transitionCompleted completed: Bool + ) { + isTransitioning = false + if completed, + let displayed = pageViewController.viewControllers?.first, + let path = path(for: displayed), + state.completeTransition(to: path) { + selection?.wrappedValue = path + } + removeUnusedHosts() + reloadDataSourceIfNeeded() + synchronizeDisplayedPage() + } + + private func synchronizeDisplayedPage() { + guard let pageController, + let destination = host(for: state.selectedPath) else { + return + } + let current = pageController.viewControllers?.first + guard current !== destination else { return } + let direction: UIPageViewController.NavigationDirection = state.isForwardTransition( + from: current.flatMap(path(for:)), + to: state.selectedPath + ) ? .forward : .reverse + pageController.setViewControllers( + [destination], + direction: direction, + animated: false + ) + } + + private func host(for path: String) -> UIHostingController? { + if let host = hostsByPath[path] { + return host + } + guard let page = pagesByPath[path] else { return nil } + let host = UIHostingController(rootView: page) + host.view.backgroundColor = .systemBackground + host.view.isOpaque = true + host.view.clipsToBounds = true + hostsByPath[path] = host + return host + } + + private func path(for viewController: UIViewController) -> String? { + hostsByPath.first { $0.value === viewController }?.key + } + + private func removeUnusedHosts() { + let retainedPaths = Set(state.paths) + hostsByPath = hostsByPath.filter { retainedPaths.contains($0.key) } + } + + private func reloadDataSourceIfNeeded() { + guard needsDataSourceReload, let pageController else { return } + pageController.dataSource = nil + pageController.dataSource = self + needsDataSourceReload = false + } + + private func configurePaging(isEnabled: Bool) { + guard let pageController else { return } + for case let scrollView as UIScrollView in pageController.view.subviews { + scrollView.isScrollEnabled = isEnabled + scrollView.clipsToBounds = true + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRoute.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRoute.swift new file mode 100644 index 000000000000..17999175b9ff --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRoute.swift @@ -0,0 +1,11 @@ +/// Client-side viewer routes derived without expanding the artifact wire kind. +enum ChatArtifactPreviewRoute: Equatable, Sendable { + case folder + case image + case pdf + case media + case markdown + case text + case quickLook + case binary +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift new file mode 100644 index 000000000000..57984065781a --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactPreviewRouter.swift @@ -0,0 +1,67 @@ +import CmuxAgentChat +import Foundation +import UniformTypeIdentifiers + +/// Resolves rich client preview routes from wire metadata and the filename. +struct ChatArtifactPreviewRouter: Sendable { + /// Chooses the viewer route while preserving the four-case wire kind. + /// + /// Quick Look candidates still require a local + /// `QLPreviewController.canPreview(_:)` check after download. + func route(stat: ChatArtifactStat, path: String) -> ChatArtifactPreviewRoute { + if stat.isDirectory { + return .folder + } + if stat.kind == .image { + return .image + } + + let fileExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + let mimeType = Self.normalizedMIMEType(stat.mimeType) + if mimeType == "application/pdf" || fileExtension == "pdf" { + return .pdf + } + + let type = fileExtension.isEmpty ? nil : UTType(filenameExtension: fileExtension) + if isMedia(type) || isMedia(mimeType.flatMap { UTType(mimeType: $0) }) { + return .media + } + if fileExtension == "md" || fileExtension == "markdown" { + return .markdown + } + if stat.kind == .text { + return .text + } + if let type, !type.isDynamic, type.conforms(to: .content) { + return .quickLook + } + return .binary + } + + /// Preferred filename extension for a wire MIME type, used to type + /// extensionless temporary files for preview frameworks. + func preferredExtension(forMIMEType rawMIMEType: String?) -> String? { + guard let mimeType = Self.normalizedMIMEType(rawMIMEType), + let type = UTType(mimeType: mimeType) else { + return nil + } + return type.preferredFilenameExtension + } + + private func isMedia(_ type: UTType?) -> Bool { + guard let type else { return false } + return type.conforms(to: .movie) || type.conforms(to: .audio) + } + + private static func normalizedMIMEType(_ rawValue: String?) -> String? { + guard let normalized = rawValue? + .split(separator: ";", maxSplits: 1) + .first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !normalized.isEmpty else { + return nil + } + return normalized + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookCoordinator.swift new file mode 100644 index 000000000000..0996d8da327b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookCoordinator.swift @@ -0,0 +1,28 @@ +#if os(iOS) +import QuickLook + +/// Retains and supplies the current artifact item to a Quick Look controller. +@MainActor +final class ChatArtifactQuickLookCoordinator: NSObject, QLPreviewControllerDataSource { + private(set) var item: ChatArtifactQuickLookItem + + init(item: ChatArtifactQuickLookItem) { + self.item = item + } + + func update(item: ChatArtifactQuickLookItem) { + self.item = item + } + + func numberOfPreviewItems(in controller: QLPreviewController) -> Int { + 1 + } + + func previewController( + _ controller: QLPreviewController, + previewItemAt index: Int + ) -> any QLPreviewItem { + item + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookItem.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookItem.swift new file mode 100644 index 000000000000..1870db2e868a --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookItem.swift @@ -0,0 +1,15 @@ +#if os(iOS) +import Foundation +import QuickLook + +/// Supplies a local artifact URL and original display name to Quick Look. +final class ChatArtifactQuickLookItem: NSObject, QLPreviewItem { + let previewItemURL: URL? + let previewItemTitle: String? + + init(fileURL: URL, title: String?) { + previewItemURL = fileURL + previewItemTitle = title + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookView.swift new file mode 100644 index 000000000000..6da09f7cfb75 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactQuickLookView.swift @@ -0,0 +1,29 @@ +#if os(iOS) +import QuickLook +import SwiftUI + +/// Hosts the system Quick Look document preview for one local artifact file. +struct ChatArtifactQuickLookView: UIViewControllerRepresentable { + let fileURL: URL + let title: String + + func makeCoordinator() -> ChatArtifactQuickLookCoordinator { + ChatArtifactQuickLookCoordinator( + item: ChatArtifactQuickLookItem(fileURL: fileURL, title: title) + ) + } + + func makeUIViewController(context: Context) -> QLPreviewController { + let controller = QLPreviewController() + controller.dataSource = context.coordinator + return controller + } + + func updateUIViewController(_ controller: QLPreviewController, context: Context) { + context.coordinator.update( + item: ChatArtifactQuickLookItem(fileURL: fileURL, title: title) + ) + controller.reloadData() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchBar.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchBar.swift new file mode 100644 index 000000000000..92a720d700e3 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchBar.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Inline in-file search controls for the currently loaded artifact text. +struct ChatArtifactSearchBar: View { + @Binding var query: String + let summary: ChatArtifactSearchSummary + let isStillLoading: Bool + let onPrevious: () -> Void + let onNext: () -> Void + let onClose: () -> Void + + @FocusState private var isSearchFieldFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + searchField + + if !query.isEmpty { + Text(verbatim: "\(summary.currentPosition)/\(summary.matchCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .fixedSize() + } + + Button(action: onPrevious) { + Image(systemName: "chevron.up") + } + .disabled(query.isEmpty || summary.matchCount == 0) + .accessibilityLabel(String( + localized: "chat.artifact.search.previous", + defaultValue: "Previous match", + bundle: .module + )) + + Button(action: onNext) { + Image(systemName: "chevron.down") + } + .disabled(query.isEmpty || summary.matchCount == 0) + .accessibilityLabel(String( + localized: "chat.artifact.search.next", + defaultValue: "Next match", + bundle: .module + )) + + Button { + isSearchFieldFocused = false + onClose() + } label: { + Image(systemName: "xmark.circle.fill") + } + .accessibilityLabel(String( + localized: "chat.artifact.search.close", + defaultValue: "Close search", + bundle: .module + )) + } + .buttonStyle(.plain) + + if isStillLoading, !query.isEmpty { + HStack(spacing: 5) { + ProgressView() + .controlSize(.mini) + Text(String( + localized: "chat.artifact.search.still_loading", + defaultValue: "Still loading", + bundle: .module + )) + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.bar) + .onAppear { + isSearchFieldFocused = true + } + .onDisappear { + isSearchFieldFocused = false + } + } + + @ViewBuilder + private var searchField: some View { + #if os(iOS) + baseSearchField + .textInputAutocapitalization(.never) + #else + baseSearchField + #endif + } + + private var baseSearchField: some View { + TextField( + String( + localized: "chat.artifact.search.placeholder", + defaultValue: "Find in file", + bundle: .module + ), + text: $query + ) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .submitLabel(.search) + .focused($isSearchFieldFocused) + .onSubmit { + isSearchFieldFocused = false + onNext() + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchModel.swift new file mode 100644 index 000000000000..2544316dce7e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchModel.swift @@ -0,0 +1,95 @@ +import Foundation + +/// Pure literal-search state over the currently loaded UTF-16 text range. +struct ChatArtifactSearchModel: Equatable, Sendable { + private(set) var query: String + private(set) var matchRanges: [NSRange] + private(set) var selectedMatchIndex: Int? + + init(query: String = "", text: String = "") { + self.query = query + matchRanges = Self.ranges(of: query, in: text) + selectedMatchIndex = matchRanges.isEmpty ? nil : 0 + } + + var currentRange: NSRange? { + guard let selectedMatchIndex, + matchRanges.indices.contains(selectedMatchIndex) else { + return nil + } + return matchRanges[selectedMatchIndex] + } + + var summary: ChatArtifactSearchSummary { + ChatArtifactSearchSummary( + currentPosition: selectedMatchIndex.map { $0 + 1 } ?? 0, + matchCount: matchRanges.count + ) + } + + /// Replaces the literal query and starts selection at the first loaded match. + mutating func update(query: String, in text: String) { + guard query == self.query else { + self.query = query + matchRanges = Self.ranges(of: query, in: text) + selectedMatchIndex = matchRanges.isEmpty ? nil : 0 + return + } + recompute(in: text) + } + + /// Recomputes after a stream append while preserving the current match when possible. + mutating func recompute(in text: String) { + let selectedRange = currentRange + matchRanges = Self.ranges(of: query, in: text) + if let selectedRange, + let preservedIndex = matchRanges.firstIndex(of: selectedRange) { + selectedMatchIndex = preservedIndex + } else { + selectedMatchIndex = matchRanges.isEmpty ? nil : 0 + } + } + + /// Advances to the next result, wrapping from the last match to the first. + mutating func selectNext() { + guard !matchRanges.isEmpty else { + selectedMatchIndex = nil + return + } + selectedMatchIndex = ((selectedMatchIndex ?? -1) + 1) % matchRanges.count + } + + /// Moves to the previous result, wrapping from the first match to the last. + mutating func selectPrevious() { + guard !matchRanges.isEmpty else { + selectedMatchIndex = nil + return + } + let currentIndex = selectedMatchIndex ?? 0 + selectedMatchIndex = (currentIndex - 1 + matchRanges.count) % matchRanges.count + } + + private static func ranges(of query: String, in text: String) -> [NSRange] { + guard !query.isEmpty else { return [] } + let source = text as NSString + var searchRange = NSRange(location: 0, length: source.length) + var results: [NSRange] = [] + + while searchRange.length > 0 { + let match = source.range( + of: query, + options: [.caseInsensitive, .literal], + range: searchRange + ) + guard match.location != NSNotFound else { break } + results.append(match) + let nextLocation = NSMaxRange(match) + guard nextLocation > searchRange.location else { break } + searchRange = NSRange( + location: nextLocation, + length: source.length - nextLocation + ) + } + return results + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchSummary.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchSummary.swift new file mode 100644 index 000000000000..c41034c5254e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSearchSummary.swift @@ -0,0 +1,7 @@ +/// The small search-state snapshot needed by the SwiftUI search chrome. +struct ChatArtifactSearchSummary: Equatable, Sendable { + static let empty = ChatArtifactSearchSummary(currentPosition: 0, matchCount: 0) + + let currentPosition: Int + let matchCount: Int +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift new file mode 100644 index 000000000000..363a2d9b50c6 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlightPolicy.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Selects syntax highlighting without spending unbounded work on large artifacts. +struct ChatArtifactSyntaxHighlightPolicy: Sendable { + static let maxHighlightBytes: Int64 = 1_500_000 + static let maxAutomaticDetectionBytes: Int64 = 256_000 + + private static let languagesByExtension: [String: String] = [ + "bash": "bash", + "c": "c", + "cc": "cpp", + "cjs": "javascript", + "clj": "clojure", + "cljs": "clojure", + "cpp": "cpp", + "cs": "csharp", + "css": "css", + "cxx": "cpp", + "dart": "dart", + "ex": "elixir", + "exs": "elixir", + "fs": "fsharp", + "fsx": "fsharp", + "go": "go", + "gradle": "gradle", + "groovy": "groovy", + "h": "c", + "hh": "cpp", + "hpp": "cpp", + "htm": "html", + "html": "html", + "java": "java", + "js": "javascript", + "json": "json", + "jsx": "javascript", + "kt": "kotlin", + "kts": "kotlin", + "less": "less", + "lua": "lua", + "m": "objectivec", + "markdown": "markdown", + "md": "markdown", + "mjs": "javascript", + "mm": "objectivec", + "php": "php", + "pl": "perl", + "pm": "perl", + "py": "python", + "r": "r", + "rb": "ruby", + "rs": "rust", + "sass": "scss", + "scala": "scala", + "scss": "scss", + "sh": "bash", + "sql": "sql", + "swift": "swift", + "ts": "typescript", + "tsx": "typescript", + "xml": "xml", + "yaml": "yaml", + "yml": "yaml", + "zsh": "bash", + ] + + /// Returns the bounded highlighting decision for a path and its real byte size. + func decision(path: String, byteCount: Int64) -> ChatArtifactHighlightDecision { + guard byteCount <= Self.maxHighlightBytes else { + return .skippedForSize + } + + if let language = inferredLanguage(path: path) { + return .highlight(language: language) + } + if byteCount < Self.maxAutomaticDetectionBytes { + return .highlight(language: nil) + } + return .skippedNoLanguage + } + + /// Returns a highlight.js language inferred strictly from the path extension. + func inferredLanguage(path: String) -> String? { + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + return Self.languagesByExtension[pathExtension] + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlighter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlighter.swift new file mode 100644 index 000000000000..2056973ec97d --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactSyntaxHighlighter.swift @@ -0,0 +1,30 @@ +import Foundation +@preconcurrency import Highlightr + +/// Serializes JavaScriptCore-backed highlighting away from the main actor. +actor ChatArtifactSyntaxHighlighter { + private var engine: Highlightr? + + /// Highlights a complete artifact using the palette for the current appearance. + func highlight( + text: String, + language: String?, + theme: ChatArtifactHighlightTheme + ) -> ChatArtifactHighlightedText? { + let highlightr: Highlightr + if let engine { + highlightr = engine + } else { + guard let newEngine = Highlightr() else { return nil } + engine = newEngine + highlightr = newEngine + } + + let themeName = theme == .dark ? "xcode-dark" : "xcode" + guard highlightr.setTheme(to: themeName), + let highlighted = highlightr.highlight(text, as: language) else { + return nil + } + return ChatArtifactHighlightedText(highlighted) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift new file mode 100644 index 000000000000..8766de102e5f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileStore.swift @@ -0,0 +1,72 @@ +import CmuxAgentChat +import Foundation + +/// Fetches streamed artifacts into a dedicated purgeable caches directory. +actor ChatArtifactTemporaryFileStore { + private let directory: URL + + init(directory: URL? = nil) { + if let directory { + self.directory = directory + } else { + let cachesDirectory = FileManager.default.urls( + for: .cachesDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + self.directory = cachesDirectory.appendingPathComponent( + "cmux-artifact-previews", + isDirectory: true + ) + } + } + + /// Streams a bounded artifact to disk without accumulating its bytes in memory. + func fetch( + path: String, + expectedSize: Int64, + modifiedAt: Date? = nil, + limit: Int64, + fallbackExtension: String? = nil, + preferredFilename: String? = nil, + loader: ChatArtifactLoader, + progress: @escaping @Sendable (ChatArtifactChunk) async -> Void + ) async throws -> URL { + guard expectedSize <= limit else { + throw ChatArtifactError.tooLarge(limitBytes: limit) + } + let originalExtension = URL(fileURLWithPath: path).pathExtension + let fileExtension = originalExtension.isEmpty + ? (fallbackExtension ?? "") + : originalExtension + let writer = try ChatArtifactTemporaryFileWriter( + directory: directory, + fileExtension: fileExtension, + preferredFilename: preferredFilename + ) + do { + try await loader.stream( + path: path, + modifiedAt: modifiedAt, + size: expectedSize + ) { chunk in + try Task.checkCancellation() + try await writer.append(chunk, limit: limit) + await progress(chunk) + } + try Task.checkCancellation() + return try await writer.finish() + } catch { + await writer.discard() + throw error + } + } + + func remove(_ fileURL: URL) { + try? FileManager.default.removeItem(at: fileURL) + let parent = fileURL.deletingLastPathComponent() + if parent != directory, + parent.deletingLastPathComponent() == directory { + try? FileManager.default.removeItem(at: parent) + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift new file mode 100644 index 000000000000..b10aaf2c4bc5 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTemporaryFileWriter.swift @@ -0,0 +1,75 @@ +import CmuxAgentChat +import Foundation + +/// Serializes one streamed artifact into an ordered temporary file. +actor ChatArtifactTemporaryFileWriter { + let fileURL: URL + + private var fileHandle: FileHandle? + private var nextOffset: Int64 = 0 + + init( + directory: URL, + fileExtension: String, + preferredFilename: String? = nil + ) throws { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + let fileURL: URL + if let preferredFilename { + let itemDirectory = directory.appendingPathComponent( + UUID().uuidString, + isDirectory: true + ) + try FileManager.default.createDirectory( + at: itemDirectory, + withIntermediateDirectories: true + ) + fileURL = itemDirectory.appendingPathComponent(preferredFilename) + } else { + var generatedURL = directory.appendingPathComponent(UUID().uuidString) + if !fileExtension.isEmpty { + generatedURL.appendPathExtension(fileExtension) + } + fileURL = generatedURL + } + guard FileManager.default.createFile(atPath: fileURL.path, contents: nil) else { + throw CocoaError(.fileWriteUnknown) + } + self.fileURL = fileURL + do { + fileHandle = try FileHandle(forWritingTo: fileURL) + } catch { + try? FileManager.default.removeItem(at: fileURL) + throw error + } + } + + func append(_ chunk: ChatArtifactChunk, limit: Int64) throws { + guard chunk.offset == nextOffset, + chunk.totalSize <= limit, + nextOffset <= limit - Int64(chunk.data.count), + let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.write(contentsOf: chunk.data) + nextOffset += Int64(chunk.data.count) + } + + func finish() throws -> URL { + guard let fileHandle else { + throw ChatArtifactError.macUnreachable + } + try fileHandle.close() + self.fileHandle = nil + return fileURL + } + + func discard() { + try? fileHandle?.close() + fileHandle = nil + try? FileManager.default.removeItem(at: fileURL) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAccessibilityContent.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAccessibilityContent.swift new file mode 100644 index 000000000000..d17eee20e59e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAccessibilityContent.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Retains a bounded prefix for the artifact text view's single accessibility element. +struct ChatArtifactTextAccessibilityContent: Equatable, Sendable { + static let maximumCharacterCount = 2_048 + + private(set) var excerpt = "" + private(set) var isTruncated = false + + mutating func append(_ text: String) { + guard !text.isEmpty else { return } + let remaining = Self.maximumCharacterCount - excerpt.count + guard remaining > 0 else { + isTruncated = true + return + } + let prefix = text.prefix(remaining) + excerpt.append(contentsOf: prefix) + if prefix.endIndex != text.endIndex { + isTruncated = true + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift new file mode 100644 index 000000000000..e234612724cf --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextAppendPolicy.swift @@ -0,0 +1,60 @@ +/// Coalesces streamed text chunks while scrolling must remain uninterrupted. +struct ChatArtifactTextAppendPolicy: Equatable, Sendable { + private enum DeferralState: Equatable, Sendable { + case idle + case tracking + case decelerating + case programmaticAnimation + } + + private var state = DeferralState.idle + private var pendingChunkCount = 0 + + // Only user-driven scrolling defers appends. Programmatic (pin-owned) + // scrolls must not: the pin exists to reveal new content, and a missed + // end-of-animation callback would otherwise strand every later chunk + // in the pending queue with the storage silently truncated. + var isDeferring: Bool { + state == .tracking || state == .decelerating + } + + mutating func enqueue(chunkCount: Int) -> Int { + guard chunkCount > 0 else { return 0 } + pendingChunkCount += chunkCount + return isDeferring ? 0 : drain() + } + + mutating func beginTracking() { + state = .tracking + } + + mutating func endTracking(willDecelerate: Bool) -> Int { + state = willDecelerate ? .decelerating : .idle + return state == .idle ? drain() : 0 + } + + mutating func endDecelerating() -> Int { + state = .idle + return drain() + } + + mutating func beginProgrammaticAnimation() { + state = .programmaticAnimation + } + + mutating func endProgrammaticAnimation() -> Int { + guard state == .programmaticAnimation else { return 0 } + state = .idle + return drain() + } + + mutating func reset() { + state = .idle + pendingChunkCount = 0 + } + + private mutating func drain() -> Int { + defer { pendingChunkCount = 0 } + return pendingChunkCount + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomBoundary.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomBoundary.swift new file mode 100644 index 000000000000..d84eeb6aa193 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomBoundary.swift @@ -0,0 +1,5 @@ +/// One measured TextKit document end used by bottom-pin reconciliation. +struct ChatArtifactTextBottomBoundary: Equatable, Sendable { + let storageEnd: Int + let contentOffsetY: Double +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift new file mode 100644 index 000000000000..09f82eaeeb6c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextBottomPinStateMachine.swift @@ -0,0 +1,121 @@ +/// Owns the durable follow-tail state entered by an artifact End jump. +struct ChatArtifactTextBottomPinStateMachine: Equatable, Sendable { + enum Phase: Equatable, Sendable { + case initialAnimation + case following + } + + enum Action: Equatable, Sendable { + case none + case scrollToBottom( + boundary: ChatArtifactTextBottomBoundary, + animated: Bool + ) + } + + private(set) var target: ChatArtifactTextEndJumpTarget? + private(set) var phase: Phase? + private(set) var visibleBoundary: ChatArtifactTextBottomBoundary? + private var requestedBoundary: ChatArtifactTextBottomBoundary? + + var isPinned: Bool { + target != nil + } + + mutating func engage( + target: ChatArtifactTextEndJumpTarget, + boundary: ChatArtifactTextBottomBoundary + ) -> Action { + self.target = target + phase = .initialAnimation + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: true) + } + + mutating func initialAnimationSettled( + at boundary: ChatArtifactTextBottomBoundary, + isBoundaryVisible: Bool = false + ) -> Action { + guard isPinned else { return .none } + let targetBoundary = latestBoundary( + requestedBoundary, + or: boundary + ) + requestedBoundary = targetBoundary + guard isBoundaryVisible, targetBoundary == boundary else { + visibleBoundary = nil + return .scrollToBottom(boundary: targetBoundary, animated: false) + } + phase = .following + visibleBoundary = boundary + requestedBoundary = boundary + return .none + } + + mutating func layoutChanged( + to boundary: ChatArtifactTextBottomBoundary + ) -> Action { + guard phase == .following, + requestedBoundary != boundary else { return .none } + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: false) + } + + mutating func appendsFlushed( + at boundary: ChatArtifactTextBottomBoundary + ) -> Action { + guard isPinned else { return .none } + guard phase == .following else { + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: true) + } + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: false) + } + + mutating func reachedEOF( + at boundary: ChatArtifactTextBottomBoundary + ) -> Action { + guard markReachedEOF() else { return .none } + guard phase == .following else { return .none } + visibleBoundary = nil + requestedBoundary = boundary + return .scrollToBottom(boundary: boundary, animated: false) + } + + @discardableResult + mutating func markReachedEOF() -> Bool { + guard target == .latest else { return false } + target = .end + return true + } + + mutating func didApplyPin(at boundary: ChatArtifactTextBottomBoundary) { + guard isPinned, requestedBoundary == boundary else { return } + phase = .following + visibleBoundary = boundary + requestedBoundary = boundary + } + + mutating func userInteracted() { + target = nil + phase = nil + visibleBoundary = nil + requestedBoundary = nil + } + + private func latestBoundary( + _ first: ChatArtifactTextBottomBoundary?, + or second: ChatArtifactTextBottomBoundary + ) -> ChatArtifactTextBottomBoundary { + guard let first else { return second } + if first.storageEnd != second.storageEnd { + return first.storageEnd > second.storageEnd ? first : second + } + return first.contentOffsetY > second.contentOffsetY ? first : second + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift new file mode 100644 index 000000000000..4122bd31d3be --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextContainerView.swift @@ -0,0 +1,150 @@ +#if canImport(UIKit) +import UIKit + +/// Hosts the TextKit 1 text view beside its independently redrawn line gutter. +@MainActor +final class ChatArtifactTextContainerView: UIView { + let textView: UITextView + let gutterView = ChatArtifactLineNumberGutterView() + + private let gutterWidthConstraint: NSLayoutConstraint + private var wrapsLines = true + + override init(frame: CGRect) { + textView = ChatArtifactUIKitTextView() + gutterWidthConstraint = gutterView.widthAnchor.constraint(equalToConstant: 0) + super.init(frame: frame) + + backgroundColor = .clear + isAccessibilityElement = true + accessibilityTraits = [.staticText, .allowsDirectInteraction] + textView.layoutManager.allowsNonContiguousLayout = true + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = true + textView.backgroundColor = .clear + textView.adjustsFontForContentSizeCategory = true + textView.font = .monospacedSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, + weight: .regular + ) + textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) + textView.textContainer.lineFragmentPadding = 0 + textView.isAccessibilityElement = false + textView.accessibilityElementsHidden = true + gutterView.accessibilityElementsHidden = true + + gutterView.textView = textView + gutterView.translatesAutoresizingMaskIntoConstraints = false + textView.translatesAutoresizingMaskIntoConstraints = false + addSubview(gutterView) + addSubview(textView) + NSLayoutConstraint.activate([ + gutterView.leadingAnchor.constraint(equalTo: leadingAnchor), + gutterView.topAnchor.constraint(equalTo: topAnchor), + gutterView.bottomAnchor.constraint(equalTo: bottomAnchor), + gutterWidthConstraint, + textView.leadingAnchor.constraint(equalTo: gutterView.trailingAnchor), + textView.trailingAnchor.constraint(equalTo: trailingAnchor), + textView.topAnchor.constraint(equalTo: topAnchor), + textView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + /// Exposes one bounded element so accessibility never enumerates TextKit line fragments. + func updateAccessibility( + documentID: String, + content: ChatArtifactTextAccessibilityContent + ) { + accessibilityLabel = URL(fileURLWithPath: documentID).lastPathComponent + accessibilityValue = content.excerpt + accessibilityHint = content.isTruncated + ? String( + localized: "chat.artifact.accessibility.large_text_truncated", + defaultValue: "Only the beginning of this large document is exposed to assistive technologies.", + bundle: .module + ) + : nil + } + + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + if wrapsLines { + textView.textContainer.size = CGSize( + width: max( + 0, + textView.bounds.width + - textView.textContainerInset.left + - textView.textContainerInset.right + ), + height: .greatestFiniteMagnitude + ) + } + gutterView.setNeedsDisplay() + } + + /// Switches between viewport-width wrapping and an unbounded horizontal container. + func updateWordWrap(_ wrapsLines: Bool) { + guard self.wrapsLines != wrapsLines else { return } + self.wrapsLines = wrapsLines + let contentOffset = textView.contentOffset + textView.textContainer.widthTracksTextView = wrapsLines + textView.textContainer.lineBreakMode = wrapsLines ? .byWordWrapping : .byClipping + textView.alwaysBounceHorizontal = !wrapsLines + textView.showsHorizontalScrollIndicator = !wrapsLines + if wrapsLines { + textView.textContainer.size = CGSize( + width: max( + 0, + textView.bounds.width + - textView.textContainerInset.left + - textView.textContainerInset.right + ), + height: .greatestFiniteMagnitude + ) + textView.setContentOffset( + CGPoint(x: -textView.adjustedContentInset.left, y: contentOffset.y), + animated: false + ) + } else { + textView.textContainer.size = CGSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.setContentOffset(contentOffset, animated: false) + } + textView.layoutManager.invalidateLayout( + forCharacterRange: NSRange(location: 0, length: textView.textStorage.length), + actualCharacterRange: nil + ) + gutterView.setNeedsDisplay() + } + + /// Refreshes the immutable line snapshot and the width needed for its largest number. + func updateLineNumbers( + index: ChatArtifactLineIndex, + isVisible: Bool + ) { + gutterView.lineIndex = index + gutterView.textFontPointSize = textView.font?.pointSize + ?? UIFont.preferredFont(forTextStyle: .body).pointSize + gutterView.isHidden = !isVisible + if isVisible { + let digitCount = max(2, String(index.lineCount).count) + let font = UIFont.monospacedDigitSystemFont( + ofSize: max(9, gutterView.textFontPointSize * 0.78), + weight: .regular + ) + let digitWidth = ("0" as NSString).size(withAttributes: [.font: font]).width + gutterWidthConstraint.constant = ceil(digitWidth * CGFloat(digitCount) + 18) + } else { + gutterWidthConstraint.constant = 0 + } + gutterView.setNeedsDisplay() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextEndJumpTarget.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextEndJumpTarget.swift new file mode 100644 index 000000000000..cf63fb548812 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextEndJumpTarget.swift @@ -0,0 +1,11 @@ +/// Semantic destination for the artifact viewer's trailing-edge jump. +enum ChatArtifactTextEndJumpTarget: Equatable, Sendable { + /// Most recently loaded text while content is still streaming. + case latest + /// Final document end after EOF is known. + case end + + init(reachedEOF: Bool) { + self = reachedEOF ? .end : .latest + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift new file mode 100644 index 000000000000..707d71331844 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextJumpConvergence.swift @@ -0,0 +1,42 @@ +/// Bounded policy for retargeting a document-boundary scroll after layout changes. +struct ChatArtifactTextJumpConvergence: Equatable, Sendable { + enum Decision: Equatable, Sendable { + case finish + case retarget(offset: Double) + case force(offset: Double) + } + + private let tolerance: Double + private var previousTargetOffset: Double + private var remainingRetargetCount: Int + // A synchronous settle callback must not claim the forced fallback twice. + private var exhaustedRetargetBudget = false + + init( + initialTargetOffset: Double, + maximumRetargetCount: Int = 24, + tolerance: Double = 0.5 + ) { + previousTargetOffset = initialTargetOffset + remainingRetargetCount = max(0, maximumRetargetCount) + self.tolerance = max(0, tolerance) + } + + mutating func decision(observedOffset: Double, targetOffset: Double) -> Decision { + guard !exhaustedRetargetBudget else { return .finish } + + let reachedTarget = abs(observedOffset - targetOffset) <= tolerance + let targetIsStable = abs(previousTargetOffset - targetOffset) <= tolerance + if reachedTarget, targetIsStable { + return .finish + } + + previousTargetOffset = targetOffset + guard remainingRetargetCount > 0 else { + exhaustedRetargetBudget = true + return .force(offset: targetOffset) + } + remainingRetargetCount -= 1 + return .retarget(offset: targetOffset) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextLayoutKind.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextLayoutKind.swift new file mode 100644 index 000000000000..a6fa42cc5dc0 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextLayoutKind.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Persistence buckets for artifact text layout choices. +enum ChatArtifactTextLayoutKind: String, Equatable, Sendable { + case code + case log + case plainText + + init(path: String) { + let pathExtension = URL(fileURLWithPath: path).pathExtension.lowercased() + if pathExtension == "log" || pathExtension == "out" { + self = .log + } else if ChatArtifactSyntaxHighlightPolicy().inferredLanguage(path: path) != nil { + self = .code + } else { + self = .plainText + } + } + + var defaultWrapsLines: Bool { + self != .log + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextPreferences.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextPreferences.swift new file mode 100644 index 000000000000..9ccd0f04650c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextPreferences.swift @@ -0,0 +1,61 @@ +import Foundation + +/// Stores independent wrap and font-size choices for each artifact text kind. +struct ChatArtifactTextPreferences { + static let minimumFontSize = 8.0 + static let maximumFontSize = 28.0 + static let defaultFontSize = 15.0 + + private let defaults: UserDefaults + private let keyPrefix: String + + init( + defaults: UserDefaults, + keyPrefix: String = "cmux.artifactText" + ) { + self.defaults = defaults + self.keyPrefix = keyPrefix + } + + /// Returns the saved wrap choice or the kind-specific default. + func wrapsLines(for kind: ChatArtifactTextLayoutKind) -> Bool { + let key = wrapKey(for: kind) + guard defaults.object(forKey: key) != nil else { + return kind.defaultWrapsLines + } + return defaults.bool(forKey: key) + } + + /// Saves an explicit wrap choice for one text kind. + func setWrapsLines(_ wrapsLines: Bool, for kind: ChatArtifactTextLayoutKind) { + defaults.set(wrapsLines, forKey: wrapKey(for: kind)) + } + + /// Returns the saved, clamped monospaced font size for one text kind. + func fontSize(for kind: ChatArtifactTextLayoutKind) -> Double { + guard defaults.object(forKey: fontSizeKey(for: kind)) != nil else { + return Self.defaultFontSize + } + return Self.clamped(defaults.double(forKey: fontSizeKey(for: kind))) + } + + /// Saves and returns a clamped monospaced font size for one text kind. + @discardableResult + func setFontSize(_ fontSize: Double, for kind: ChatArtifactTextLayoutKind) -> Double { + let clamped = Self.clamped(fontSize) + defaults.set(clamped, forKey: fontSizeKey(for: kind)) + return clamped + } + + private func wrapKey(for kind: ChatArtifactTextLayoutKind) -> String { + "\(keyPrefix).wrap.\(kind.rawValue)" + } + + private func fontSizeKey(for kind: ChatArtifactTextLayoutKind) -> String { + "\(keyPrefix).fontSize.\(kind.rawValue)" + } + + private static func clamped(_ fontSize: Double) -> Double { + min(max(fontSize, minimumFontSize), maximumFontSize) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextStreamBatcher.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextStreamBatcher.swift new file mode 100644 index 000000000000..dce18c1e1d39 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextStreamBatcher.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Splits decoded artifact text into bounded main-actor presentation updates. +struct ChatArtifactTextStreamBatcher: Sendable { + static let maximumBatchBytes = 256 * 1_024 + + let maximumBatchBytes: Int + + init(maximumBatchBytes: Int = Self.maximumBatchBytes) { + self.maximumBatchBytes = max(1, maximumBatchBytes) + } + + func batches(for text: String) -> [String] { + guard text.utf8.count > maximumBatchBytes else { + return text.isEmpty ? [] : [text] + } + + var result: [String] = [] + var start = text.startIndex + var remainingBytes = text.utf8.count + while start < text.endIndex { + guard remainingBytes > maximumBatchBytes else { + result.append(String(text[start...])) + break + } + + let utf8Start = start.samePosition(in: text.utf8)! + var utf8End = text.utf8.index(utf8Start, offsetBy: maximumBatchBytes) + while String.Index(utf8End, within: text) == nil { + utf8End = text.utf8.index(before: utf8End) + } + let end = String.Index(utf8End, within: text)! + result.append(String(text[start.. UITextView { - let textView = UITextView() - textView.isEditable = false - textView.isSelectable = true - textView.isScrollEnabled = true - textView.backgroundColor = .clear - textView.adjustsFontForContentSizeCategory = true - textView.font = .monospacedSystemFont( + let documentID: String + let chunks: [String] + let reachedEOF: Bool + let highlightDecision: ChatArtifactHighlightDecision + let highlightTheme: ChatArtifactHighlightTheme + let searchQuery: String + let previousSearchRequestID: Int + let nextSearchRequestID: Int + let onSearchSummaryChanged: (ChatArtifactSearchSummary) -> Void + let lineIndex: ChatArtifactLineIndex + let showsLineNumbers: Bool + let goToLineUTF16Offset: Int + let goToLineRequestID: Int + let wrapsLines: Bool + let fontPointSize: Double + let onFontSizeChanged: (Double) -> Void + let topRequestID: Int + let bottomRequestID: Int + + func makeCoordinator() -> ChatArtifactTextViewCoordinator { + ChatArtifactTextViewCoordinator() + } + + func makeUIView(context: Context) -> ChatArtifactTextContainerView { + // The container constructs an explicit TextKit 1 storage/layout stack + // so non-contiguous layout remains genuinely viewport-lazy. + let containerView = ChatArtifactTextContainerView() + containerView.textView.delegate = context.coordinator + context.coordinator.attach(containerView) + context.coordinator.onFontSizeChanged = onFontSizeChanged + return containerView + } + + func updateUIView(_ containerView: ChatArtifactTextContainerView, context: Context) { + let textView = containerView.textView + context.coordinator.onFontSizeChanged = onFontSizeChanged + let isNewDocument = context.coordinator.documentID != documentID + if isNewDocument { + context.coordinator.resetStreamingText() + context.coordinator.resetHighlighting() + context.coordinator.resetSearch() + context.coordinator.resetAccessibilityContent() + textView.textStorage.setAttributedString(NSAttributedString()) + textView.selectedRange = NSRange(location: 0, length: 0) + context.coordinator.documentID = documentID + context.coordinator.handledTopRequestID = topRequestID + context.coordinator.handledBottomRequestID = 0 + context.coordinator.handledGoToLineRequestID = goToLineRequestID + } + + if context.coordinator.appliedChunkCount > chunks.count { + context.coordinator.resetStreamingText() + context.coordinator.resetHighlighting() + context.coordinator.resetSearch() + context.coordinator.resetAccessibilityContent() + textView.textStorage.setAttributedString(NSAttributedString()) + } + + containerView.updateWordWrap(wrapsLines) + context.coordinator.updateFontSize(in: textView, pointSize: fontPointSize) + + let font = textView.font ?? UIFont.monospacedSystemFont( ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, weight: .regular ) - textView.textContainerInset = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) - textView.textContainer.lineFragmentPadding = 0 - textView.text = text - return textView - } + let textColor = textView.textColor ?? UIColor.label + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: textColor, + ] + if context.coordinator.appliedChunkCount < chunks.count { + context.coordinator.enqueueTextChunks( + chunks[context.coordinator.appliedChunkCount...], + attributes: attributes, + in: textView + ) + } + + let coordinator = context.coordinator + coordinator.schedulePostAppendWork { [weak coordinator, weak textView] in + guard let coordinator, let textView else { return } + let updatePlan = ChatArtifactTextUpdatePlan( + reachedEOF: reachedEOF, + highlightDecision: highlightDecision, + searchQuery: searchQuery + ) + let fullText = updatePlan.requiresFullTextSnapshot + ? textView.textStorage.string + : nil + coordinator.updateHighlighting( + in: textView, + documentID: documentID, + text: fullText, + reachedEOF: reachedEOF, + decision: highlightDecision, + theme: highlightTheme + ) + coordinator.updateSearch( + in: textView, + documentID: documentID, + text: fullText, + textLength: textView.textStorage.length, + query: searchQuery, + reachedEOF: reachedEOF, + previousRequestID: previousSearchRequestID, + nextRequestID: nextSearchRequestID, + onSummaryChanged: onSearchSummaryChanged + ) + } + coordinator.updateLineNumbers(index: lineIndex, isVisible: showsLineNumbers) + containerView.updateAccessibility( + documentID: documentID, + content: context.coordinator.accessibilityContent + ) - func updateUIView(_ textView: UITextView, context: Context) { - guard textView.text != text else { return } - textView.text = text + if isNewDocument { + coordinator.scrollToTop(in: textView, animated: false) + } else if coordinator.handledTopRequestID != topRequestID { + coordinator.handledTopRequestID = topRequestID + coordinator.scrollToTop(in: textView, animated: true) + } + if coordinator.handledBottomRequestID != bottomRequestID { + coordinator.handledBottomRequestID = bottomRequestID + coordinator.requestEndJump( + ChatArtifactTextEndJumpTarget(reachedEOF: reachedEOF), + in: textView + ) + } + if coordinator.handledGoToLineRequestID != goToLineRequestID { + coordinator.handledGoToLineRequestID = goToLineRequestID + coordinator.scrollToUTF16Offset(goToLineUTF16Offset, in: textView) + } + coordinator.reconcileEndJump(reachedEOF: reachedEOF, in: textView) } } #endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift new file mode 100644 index 000000000000..92d2d3a88714 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactTextViewCoordinator.swift @@ -0,0 +1,992 @@ +#if canImport(UIKit) +import Foundation +import UIKit + +/// Tracks which streamed chunks have been applied to one text-storage instance. +@MainActor +final class ChatArtifactTextViewCoordinator: + NSObject, + UITextViewDelegate, + UIGestureRecognizerDelegate +{ + private enum BottomPinTrigger: Int { + case layoutChanged + case appendsFlushed + case reachedEOF + } + + var documentID: String? + var appliedChunkCount = 0 + var handledTopRequestID = 0 + var handledBottomRequestID = 0 + var handledGoToLineRequestID = 0 + var onFontSizeChanged: ((Double) -> Void)? + private(set) var accessibilityContent = ChatArtifactTextAccessibilityContent() + private var appendPolicy = ChatArtifactTextAppendPolicy() + private var pendingTextChunks: [String] = [] + private var pendingTextAttributes: [NSAttributedString.Key: Any] = [:] + private var pendingLineNumberUpdate: (index: ChatArtifactLineIndex, isVisible: Bool)? + private var latestPostAppendWork: (() -> Void)? + private var topJumpConvergence: ChatArtifactTextJumpConvergence? + private var bottomPin = ChatArtifactTextBottomPinStateMachine() + private var endJumpConvergence: ChatArtifactTextJumpConvergence? + private var isSettlingBoundaryJump = false + private var pendingBottomPinTriggerMask = 0 + private weak var containerView: ChatArtifactTextContainerView? + private let syntaxHighlighter = ChatArtifactSyntaxHighlighter() + private var highlightTask: Task? + private var highlightGeneration = 0 + private var highlightedDocumentID: String? + private var highlightedTextLength = 0 + private var highlightedLanguage: String? + private var highlightedTheme: ChatArtifactHighlightTheme? + private var pendingHighlightDocumentID: String? + private var pendingHighlightTextLength = 0 + private var pendingHighlightLanguage: String? + private var pendingHighlightTheme: ChatArtifactHighlightTheme? + private var searchModel = ChatArtifactSearchModel() + private var searchTask: Task? + private var searchGeneration = 0 + private var searchedDocumentID: String? + private var searchedTextLength = 0 + private var pendingSearchDocumentID: String? + private var pendingSearchTextLength = 0 + private var pendingSearchQuery = "" + private var handledPreviousSearchRequestID = 0 + private var handledNextSearchRequestID = 0 + private var appliedSearchRange: NSRange? + private var onSearchSummaryChanged: ((ChatArtifactSearchSummary) -> Void)? + private var publishedSearchSummary = ChatArtifactSearchSummary.empty + private var summaryPublishGeneration = 0 + private let searchDebounce: Duration + private var appliedFontPointSize = 0.0 + private var pinchStartFontPointSize = 0.0 + private lazy var fontPinchGestureRecognizer: UIPinchGestureRecognizer = { + let recognizer = UIPinchGestureRecognizer(target: self, action: #selector(handleFontPinch(_:))) + recognizer.cancelsTouchesInView = false + return recognizer + }() + private lazy var bottomPinExitTapGestureRecognizer: UITapGestureRecognizer = { + let recognizer = UITapGestureRecognizer(target: self, action: #selector(handleUserTap(_:))) + recognizer.cancelsTouchesInView = false + recognizer.delegate = self + return recognizer + }() + + init(searchDebounce: Duration = .milliseconds(160)) { + self.searchDebounce = searchDebounce + super.init() + } + + func attach(_ containerView: ChatArtifactTextContainerView) { + self.containerView = containerView + containerView.textView.addGestureRecognizer(fontPinchGestureRecognizer) + containerView.textView.addGestureRecognizer(bottomPinExitTapGestureRecognizer) + if let textView = containerView.textView as? ChatArtifactUIKitTextView { + textView.onLayoutDidChange = { [weak self, weak textView] in + guard let textView else { return } + self?.textViewLayoutDidChange(in: textView) + } + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + containerView?.gutterView.setNeedsDisplay() + } + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + cancelBoundaryJumpOwnership() + appendPolicy.beginTracking() + suspendTextStorageWork() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + resumeDeferredUpdates( + releasedChunkCount: appendPolicy.endTracking(willDecelerate: decelerate), + in: scrollView as? UITextView + ) + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + resumeDeferredUpdates( + releasedChunkCount: appendPolicy.endDecelerating(), + in: scrollView as? UITextView + ) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + resumeDeferredUpdates( + releasedChunkCount: appendPolicy.endProgrammaticAnimation(), + in: scrollView as? UITextView + ) + } + + private func textViewLayoutDidChange(in textView: UITextView) { + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .layoutChanged) + } + + private func cancelBottomPinOwnership() { + endJumpConvergence = nil + pendingBottomPinTriggerMask = 0 + bottomPin.userInteracted() + } + + private func cancelBoundaryJumpOwnership() { + topJumpConvergence = nil + cancelBottomPinOwnership() + } + + func resetStreamingText() { + appendPolicy.reset() + pendingTextChunks.removeAll(keepingCapacity: false) + pendingTextAttributes.removeAll(keepingCapacity: false) + pendingLineNumberUpdate = nil + latestPostAppendWork = nil + topJumpConvergence = nil + bottomPin = ChatArtifactTextBottomPinStateMachine() + endJumpConvergence = nil + pendingBottomPinTriggerMask = 0 + appliedChunkCount = 0 + } + + func enqueueTextChunks( + _ chunks: ArraySlice, + attributes: [NSAttributedString.Key: Any], + in textView: UITextView + ) { + guard !chunks.isEmpty else { return } + pendingTextChunks.append(contentsOf: chunks) + pendingTextAttributes = attributes + appliedChunkCount += chunks.count + for chunk in chunks { + appendAccessibilityContent(chunk) + } + let releasedChunkCount = appendPolicy.enqueue(chunkCount: chunks.count) + if releasedChunkCount > 0 { + flushPendingText(releasedChunkCount: releasedChunkCount, in: textView) + } + } + + func updateLineNumbers(index: ChatArtifactLineIndex, isVisible: Bool) { + pendingLineNumberUpdate = (index, isVisible) + applyPendingLineNumbersIfReady() + } + + func schedulePostAppendWork(_ work: @escaping () -> Void) { + latestPostAppendWork = work + runPostAppendWorkIfReady() + } + + func scrollToTop(in textView: UITextView, animated: Bool) { + cancelBottomPinOwnership() + let target = documentTopContentOffset(in: textView) + guard animated else { + topJumpConvergence = nil + textView.setContentOffset(target, animated: false) + return + } + + topJumpConvergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: Double(target.y) + ) + if !setContentOffset(target, animated: true, in: textView) { + topJumpConvergence = nil + } + } + + func requestEndJump( + _ target: ChatArtifactTextEndJumpTarget, + in textView: UITextView + ) { + topJumpConvergence = nil + let boundary = documentEndBoundary(in: textView) + endJumpConvergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: boundary.contentOffsetY + ) + let action = bottomPin.engage(target: target, boundary: boundary) + if !applyBottomPinAction(action, in: textView) { + settleBoundaryJumpIfReady(in: textView) + } + } + + func reconcileEndJump(reachedEOF: Bool, in textView: UITextView) { + guard reachedEOF else { return } + if endJumpConvergence != nil || appendPolicy.isDeferring { + bottomPin.markReachedEOF() + return + } + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .reachedEOF) + } + + func scrollToUTF16Offset(_ offset: Int, in textView: UITextView) { + cancelBoundaryJumpOwnership() + textView.scrollRangeToVisible(NSRange( + location: min(max(offset, 0), textView.textStorage.length), + length: 0 + )) + } + + private func resumeDeferredUpdates( + releasedChunkCount: Int, + in textView: UITextView? + ) { + if releasedChunkCount > 0, let textView { + flushPendingText(releasedChunkCount: releasedChunkCount, in: textView) + return + } + applyPendingLineNumbersIfReady() + runPostAppendWorkIfReady() + if let textView { + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .layoutChanged) + } + } + + private func flushPendingText(releasedChunkCount: Int, in textView: UITextView) { + guard !appendPolicy.isDeferring, !pendingTextChunks.isEmpty else { return } + assert(releasedChunkCount == pendingTextChunks.count) + + let text = pendingTextChunks.joined() + pendingTextChunks.removeAll(keepingCapacity: true) + let attributes = pendingTextAttributes + pendingTextAttributes.removeAll(keepingCapacity: true) + let contentOffset = textView.contentOffset + let selection = textView.selectedRange + + // TextKit can reset this optimization when its stack is rebuilt. Keep + // it explicit at the edit boundary so appends only lay out the viewport. + textView.layoutManager.allowsNonContiguousLayout = true + textView.textStorage.beginEditing() + textView.textStorage.append(NSAttributedString(string: text, attributes: attributes)) + textView.textStorage.endEditing() + textView.selectedRange = selection + textView.setContentOffset(contentOffset, animated: false) + + settleBoundaryJumpIfReady(in: textView, bottomPinTrigger: .appendsFlushed) + applyPendingLineNumbersIfReady() + runPostAppendWorkIfReady() + } + + /// Settles the bounded initial animation, then lets the durable pin own later changes. + private func settleBoundaryJumpIfReady( + in textView: UITextView, + bottomPinTrigger: BottomPinTrigger? = nil + ) { + if let bottomPinTrigger { + pendingBottomPinTriggerMask |= 1 << bottomPinTrigger.rawValue + } + // UIKit may synchronously report animation completion while a fallback + // range or a deferred append is being materialized. The active settle + // owns that edge and drains every trigger recorded by re-entrant work. + guard !isSettlingBoundaryJump else { return } + isSettlingBoundaryJump = true + defer { isSettlingBoundaryJump = false } + + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty else { return } + + let appendsFlushedMask = 1 << BottomPinTrigger.appendsFlushed.rawValue + if bottomPin.phase == .initialAnimation, + pendingBottomPinTriggerMask & appendsFlushedMask != 0 { + pendingBottomPinTriggerMask &= ~appendsFlushedMask + let boundary = documentEndBoundary(in: textView) + endJumpConvergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: boundary.contentOffsetY + ) + if applyBottomPinAction( + bottomPin.appendsFlushed(at: boundary), + in: textView + ) { + return + } + } + + while true { + while settleBoundaryJumpOnceIfReady(in: textView) {} + + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty, + endJumpConvergence == nil, + let bottomPinTrigger = takePendingBottomPinTrigger() else { return } + let boundary = documentEndBoundary(in: textView) + let action: ChatArtifactTextBottomPinStateMachine.Action + switch bottomPinTrigger { + case .layoutChanged: + action = bottomPin.layoutChanged(to: boundary) + case .appendsFlushed: + action = bottomPin.appendsFlushed(at: boundary) + case .reachedEOF: + action = bottomPin.reachedEOF(at: boundary) + } + _ = applyBottomPinAction(action, in: textView) + + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty else { return } + } + } + + private func takePendingBottomPinTrigger() -> BottomPinTrigger? { + for trigger in [ + BottomPinTrigger.reachedEOF, + .appendsFlushed, + .layoutChanged, + ] where pendingBottomPinTriggerMask & (1 << trigger.rawValue) != 0 { + pendingBottomPinTriggerMask &= ~(1 << trigger.rawValue) + return trigger + } + return nil + } + + /// Performs one budgeted settle step and reports whether layout must be sampled again now. + private func settleBoundaryJumpOnceIfReady(in textView: UITextView) -> Bool { + if var convergence = topJumpConvergence { + let target = documentTopContentOffset(in: textView) + let decision = convergence.decision( + observedOffset: Double(textView.contentOffset.y), + targetOffset: Double(target.y) + ) + topJumpConvergence = convergence + return applyTopJumpDecision(decision, target: target, in: textView) + } + + guard var convergence = endJumpConvergence else { return false } + let boundary = documentEndBoundary(in: textView) + let decision = convergence.decision( + observedOffset: Double(textView.contentOffset.y), + targetOffset: boundary.contentOffsetY + ) + endJumpConvergence = convergence + return applyEndJumpDecision( + decision, + boundary: boundary, + in: textView + ) + } + + private func applyTopJumpDecision( + _ decision: ChatArtifactTextJumpConvergence.Decision, + target: CGPoint, + in textView: UITextView + ) -> Bool { + switch decision { + case .finish: + topJumpConvergence = nil + return false + case .retarget: + if !setContentOffset(target, animated: true, in: textView) { + textView.layoutIfNeeded() + return true + } + return false + case .force: + topJumpConvergence = nil + settleAtDocumentTop(in: textView) + return false + } + } + + private func applyEndJumpDecision( + _ decision: ChatArtifactTextJumpConvergence.Decision, + boundary: ChatArtifactTextBottomBoundary, + in textView: UITextView + ) -> Bool { + switch decision { + case .finish: + endJumpConvergence = nil + _ = applyBottomPinAction( + bottomPin.initialAnimationSettled( + at: boundary, + isBoundaryVisible: true + ), + in: textView + ) + return false + case .retarget: + let target = CGPoint( + x: textView.contentOffset.x, + y: CGFloat(boundary.contentOffsetY) + ) + if !setContentOffset(target, animated: true, in: textView) { + textView.layoutIfNeeded() + return true + } + return false + case .force: + endJumpConvergence = nil + _ = applyBottomPinAction( + bottomPin.initialAnimationSettled( + at: boundary, + isBoundaryVisible: false + ), + in: textView + ) + return false + } + } + + /// Applies the state-machine decision; only the first End movement may animate. + private func applyBottomPinAction( + _ action: ChatArtifactTextBottomPinStateMachine.Action, + in textView: UITextView + ) -> Bool { + switch action { + case .none: + return false + case .scrollToBottom(let boundary, let animated): + if animated { + return setContentOffset( + CGPoint( + x: textView.contentOffset.x, + y: CGFloat(boundary.contentOffsetY) + ), + animated: true, + in: textView + ) + } + settleAtDocumentEnd(in: textView) + bottomPin.didApplyPin(at: documentEndBoundary(in: textView)) + return false + } + } + + private func setContentOffset( + _ target: CGPoint, + animated: Bool, + in textView: UITextView + ) -> Bool { + let requiresAnimation = animated + && (abs(textView.contentOffset.x - target.x) > 0.5 + || abs(textView.contentOffset.y - target.y) > 0.5) + if requiresAnimation { + appendPolicy.beginProgrammaticAnimation() + suspendTextStorageWork() + } + textView.setContentOffset(target, animated: requiresAnimation) + return requiresAnimation + } + + private func documentTopContentOffset(in textView: UITextView) -> CGPoint { + if textView.textStorage.length > 0 { + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout( + forCharacterRange: NSRange(location: 0, length: 1) + ) + } + return CGPoint( + x: -textView.adjustedContentInset.left, + y: -textView.adjustedContentInset.top + ) + } + + /// Lays out only the final TextKit 1 character range and returns its true bottom offset. + private func documentEndContentOffset(in textView: UITextView) -> CGPoint { + let minimumY = -textView.adjustedContentInset.top + var documentBottom = textView.textContainerInset.top + + textView.textContainerInset.bottom + if textView.textStorage.length > 0 { + let finalCharacterRange = NSRange( + location: textView.textStorage.length - 1, + length: 1 + ) + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout(forCharacterRange: finalCharacterRange) + let finalGlyphRange = textView.layoutManager.glyphRange( + forCharacterRange: finalCharacterRange, + actualCharacterRange: nil + ) + let finalGlyphRect = textView.layoutManager.boundingRect( + forGlyphRange: finalGlyphRange, + in: textView.textContainer + ) + documentBottom += max( + finalGlyphRect.maxY, + textView.layoutManager.extraLineFragmentRect.maxY + ) + } + return CGPoint( + x: textView.contentOffset.x, + y: max( + minimumY, + documentBottom + - textView.bounds.height + + textView.adjustedContentInset.bottom + ) + ) + } + + private func documentEndBoundary( + in textView: UITextView + ) -> ChatArtifactTextBottomBoundary { + let contentOffset = documentEndContentOffset(in: textView) + return ChatArtifactTextBottomBoundary( + storageEnd: textView.textStorage.length, + contentOffsetY: Double(contentOffset.y) + ) + } + + /// Settles at the first character without materializing the intervening document. + private func settleAtDocumentTop(in textView: UITextView) { + if textView.textStorage.length > 0 { + let range = NSRange(location: 0, length: 1) + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout(forCharacterRange: range) + textView.scrollRangeToVisible(range) + } + textView.layoutIfNeeded() + textView.setContentOffset(documentTopContentOffset(in: textView), animated: false) + } + + /// Settles at the final character without materializing the intervening document. + private func settleAtDocumentEnd(in textView: UITextView) { + if textView.textStorage.length > 0 { + let range = NSRange(location: textView.textStorage.length - 1, length: 1) + textView.layoutManager.allowsNonContiguousLayout = true + textView.layoutManager.ensureLayout(forCharacterRange: range) + textView.scrollRangeToVisible(range) + } + textView.layoutIfNeeded() + textView.setContentOffset(documentEndContentOffset(in: textView), animated: false) + } + + private func applyPendingLineNumbersIfReady() { + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty, + let update = pendingLineNumberUpdate else { return } + pendingLineNumberUpdate = nil + containerView?.updateLineNumbers(index: update.index, isVisible: update.isVisible) + } + + private func runPostAppendWorkIfReady() { + guard !appendPolicy.isDeferring, + pendingTextChunks.isEmpty else { return } + latestPostAppendWork?() + } + + private func suspendTextStorageWork() { + highlightTask?.cancel() + highlightTask = nil + highlightGeneration += 1 + pendingHighlightDocumentID = nil + pendingHighlightTextLength = 0 + pendingHighlightLanguage = nil + pendingHighlightTheme = nil + + searchTask?.cancel() + searchTask = nil + searchGeneration += 1 + pendingSearchDocumentID = nil + pendingSearchTextLength = 0 + pendingSearchQuery = "" + } + + func updateFontSize(in textView: UITextView, pointSize: Double) { + let clamped = min( + max(pointSize, ChatArtifactTextPreferences.minimumFontSize), + ChatArtifactTextPreferences.maximumFontSize + ) + guard abs(appliedFontPointSize - clamped) > 0.001 else { return } + applyFontSize(clamped, to: textView) + } + + @objc + private func handleFontPinch(_ recognizer: UIPinchGestureRecognizer) { + guard let textView = containerView?.textView else { return } + switch recognizer.state { + case .began: + cancelBoundaryJumpOwnership() + pinchStartFontPointSize = appliedFontPointSize > 0 + ? appliedFontPointSize + : Double(textView.font?.pointSize ?? 15) + case .changed, .ended: + let scaled = pinchStartFontPointSize * Double(recognizer.scale) + let quantized = (scaled * 2).rounded() / 2 + let clamped = min( + max(quantized, ChatArtifactTextPreferences.minimumFontSize), + ChatArtifactTextPreferences.maximumFontSize + ) + if abs(clamped - appliedFontPointSize) > 0.001 { + applyFontSize(clamped, to: textView) + } + if recognizer.state == .ended { + onFontSizeChanged?(clamped) + } + case .cancelled, .failed: + applyFontSize(pinchStartFontPointSize, to: textView) + default: + break + } + } + + @objc + private func handleUserTap(_ recognizer: UITapGestureRecognizer) { + guard recognizer.state == .ended else { return } + cancelBoundaryJumpOwnership() + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === bottomPinExitTapGestureRecognizer + || otherGestureRecognizer === bottomPinExitTapGestureRecognizer + } + + private func applyFontSize(_ pointSize: Double, to textView: UITextView) { + let font = UIFont.monospacedSystemFont( + ofSize: CGFloat(pointSize), + weight: .regular + ) + let contentOffset = textView.contentOffset + let selection = textView.selectedRange + textView.font = font + if textView.textStorage.length > 0 { + textView.textStorage.addAttribute( + .font, + value: font, + range: NSRange(location: 0, length: textView.textStorage.length) + ) + } + textView.selectedRange = selection + textView.setContentOffset(contentOffset, animated: false) + appliedFontPointSize = pointSize + if let containerView { + containerView.updateLineNumbers( + index: containerView.gutterView.lineIndex, + isVisible: !containerView.gutterView.isHidden + ) + } + } + + func resetHighlighting() { + highlightTask?.cancel() + highlightTask = nil + highlightGeneration += 1 + highlightedDocumentID = nil + highlightedTextLength = 0 + highlightedLanguage = nil + highlightedTheme = nil + pendingHighlightDocumentID = nil + pendingHighlightTextLength = 0 + pendingHighlightLanguage = nil + pendingHighlightTheme = nil + } + + func resetSearch() { + searchTask?.cancel() + searchTask = nil + searchGeneration += 1 + searchModel = ChatArtifactSearchModel() + searchedDocumentID = nil + searchedTextLength = 0 + pendingSearchDocumentID = nil + pendingSearchTextLength = 0 + pendingSearchQuery = "" + handledPreviousSearchRequestID = 0 + handledNextSearchRequestID = 0 + appliedSearchRange = nil + publishSearchSummary(.empty) + } + + func resetAccessibilityContent() { + accessibilityContent = ChatArtifactTextAccessibilityContent() + } + + func appendAccessibilityContent(_ text: String) { + accessibilityContent.append(text) + } + + func updateHighlighting( + in textView: UITextView, + documentID: String, + text: String?, + reachedEOF: Bool, + decision: ChatArtifactHighlightDecision, + theme: ChatArtifactHighlightTheme + ) { + guard let text, + reachedEOF, + case .highlight(let language) = decision, + !text.isEmpty else { + highlightTask?.cancel() + highlightTask = nil + return + } + guard highlightedDocumentID != documentID + || highlightedTextLength != text.utf16.count + || highlightedLanguage != language + || highlightedTheme != theme else { + return + } + guard pendingHighlightDocumentID != documentID + || pendingHighlightTextLength != text.utf16.count + || pendingHighlightLanguage != language + || pendingHighlightTheme != theme else { + return + } + + highlightTask?.cancel() + highlightGeneration += 1 + let generation = highlightGeneration + let highlighter = syntaxHighlighter + pendingHighlightDocumentID = documentID + pendingHighlightTextLength = text.utf16.count + pendingHighlightLanguage = language + pendingHighlightTheme = theme + highlightTask = Task { @MainActor [weak self, weak textView] in + let result = await highlighter.highlight( + text: text, + language: language, + theme: theme + ) + guard let self, + !Task.isCancelled, + generation == self.highlightGeneration, + let textView, + let result, + result.value.string == text, + textView.textStorage.string == text else { + return + } + + self.apply(result.value, to: textView) + self.highlightedDocumentID = documentID + self.highlightedTextLength = text.utf16.count + self.highlightedLanguage = language + self.highlightedTheme = theme + self.pendingHighlightDocumentID = nil + self.pendingHighlightTextLength = 0 + self.pendingHighlightLanguage = nil + self.pendingHighlightTheme = nil + self.highlightTask = nil + } + } + + func updateSearch( + in textView: UITextView, + documentID: String, + text: String?, + textLength: Int, + query: String, + reachedEOF: Bool, + previousRequestID: Int, + nextRequestID: Int, + onSummaryChanged: @escaping (ChatArtifactSearchSummary) -> Void + ) { + self.onSearchSummaryChanged = onSummaryChanged + guard !query.isEmpty else { + searchTask?.cancel() + searchTask = nil + searchGeneration += 1 + clearSearchHighlight(in: textView) + searchModel = ChatArtifactSearchModel() + searchedDocumentID = documentID + searchedTextLength = textLength + pendingSearchDocumentID = nil + pendingSearchTextLength = 0 + pendingSearchQuery = "" + handledPreviousSearchRequestID = previousRequestID + handledNextSearchRequestID = nextRequestID + publishSearchSummary(.empty) + return + } + + guard let text else { return } + let textLength = text.utf16.count + let hasCurrentResults = searchedDocumentID == documentID + && searchedTextLength == textLength + && searchModel.query == query + let hasPendingResults = pendingSearchDocumentID == documentID + && pendingSearchTextLength == textLength + && pendingSearchQuery == query + if !hasCurrentResults, !hasPendingResults { + scheduleSearch( + in: textView, + documentID: documentID, + text: text, + query: query, + reachedEOF: reachedEOF, + previousRequestID: previousRequestID, + nextRequestID: nextRequestID + ) + return + } + guard hasCurrentResults else { return } + applySearchNavigation( + in: textView, + previousRequestID: previousRequestID, + nextRequestID: nextRequestID + ) + } + + private func scheduleSearch( + in textView: UITextView, + documentID: String, + text: String, + query: String, + reachedEOF: Bool, + previousRequestID: Int, + nextRequestID: Int + ) { + searchTask?.cancel() + searchGeneration += 1 + let generation = searchGeneration + let previousModel = searchModel + let debounce = searchDebounce + let shouldDebounce = !reachedEOF + pendingSearchDocumentID = documentID + pendingSearchTextLength = text.utf16.count + pendingSearchQuery = query + if previousModel.query != query { + clearSearchHighlight(in: textView) + publishSearchSummary(.empty) + } + + searchTask = Task { @MainActor [weak self, weak textView] in + if shouldDebounce { + do { + // A bounded, cancellable debounce coalesces streamed append bursts. + try await Task.sleep(for: debounce) + } catch { + return + } + } + let nextModel = await Task.detached(priority: .userInitiated) { + var model = previousModel + model.update(query: query, in: text) + return model + }.value + guard let self, + !Task.isCancelled, + generation == self.searchGeneration, + let textView, + textView.textStorage.string == text else { + return + } + + let shouldScroll = previousModel.query != query + self.searchModel = nextModel + self.searchedDocumentID = documentID + self.searchedTextLength = text.utf16.count + self.pendingSearchDocumentID = nil + self.pendingSearchTextLength = 0 + self.pendingSearchQuery = "" + self.handledPreviousSearchRequestID = previousRequestID + self.handledNextSearchRequestID = nextRequestID + self.applyCurrentSearchHighlight(in: textView, scrollToMatch: shouldScroll) + self.publishSearchSummary(nextModel.summary) + self.searchTask = nil + } + } + + private func applySearchNavigation( + in textView: UITextView, + previousRequestID: Int, + nextRequestID: Int + ) { + var didNavigate = false + if handledPreviousSearchRequestID != previousRequestID { + handledPreviousSearchRequestID = previousRequestID + searchModel.selectPrevious() + didNavigate = true + } + if handledNextSearchRequestID != nextRequestID { + handledNextSearchRequestID = nextRequestID + searchModel.selectNext() + didNavigate = true + } + guard didNavigate else { return } + applyCurrentSearchHighlight(in: textView, scrollToMatch: true) + publishSearchSummary(searchModel.summary) + } + + private func apply(_ highlighted: NSAttributedString, to textView: UITextView) { + let contentOffset = textView.contentOffset + let selection = textView.selectedRange + let pointSize = textView.font?.pointSize + ?? UIFont.preferredFont(forTextStyle: .body).pointSize + let fullRange = NSRange(location: 0, length: highlighted.length) + + textView.textStorage.beginEditing() + highlighted.enumerateAttributes(in: fullRange) { attributes, range, _ in + textView.textStorage.setAttributes( + normalized(attributes, pointSize: pointSize), + range: range + ) + } + textView.textStorage.endEditing() + textView.selectedRange = selection + textView.setContentOffset(contentOffset, animated: false) + applyCurrentSearchHighlight(in: textView, scrollToMatch: false) + containerView?.gutterView.setNeedsDisplay() + } + + private func applyCurrentSearchHighlight( + in textView: UITextView, + scrollToMatch: Bool + ) { + clearSearchHighlight(in: textView) + guard let currentRange = searchModel.currentRange, + NSMaxRange(currentRange) <= textView.textStorage.length else { + return + } + textView.textStorage.addAttribute( + .backgroundColor, + value: UIColor.systemYellow.withAlphaComponent(0.38), + range: currentRange + ) + appliedSearchRange = currentRange + if scrollToMatch { + cancelBoundaryJumpOwnership() + textView.scrollRangeToVisible(currentRange) + } + } + + private func clearSearchHighlight(in textView: UITextView) { + guard let appliedSearchRange, + NSMaxRange(appliedSearchRange) <= textView.textStorage.length else { + self.appliedSearchRange = nil + return + } + textView.textStorage.removeAttribute(.backgroundColor, range: appliedSearchRange) + self.appliedSearchRange = nil + } + + private func publishSearchSummary(_ summary: ChatArtifactSearchSummary) { + guard publishedSearchSummary != summary else { return } + publishedSearchSummary = summary + summaryPublishGeneration += 1 + let generation = summaryPublishGeneration + let handler = onSearchSummaryChanged + Task { @MainActor [weak self] in + await Task.yield() + guard let self, + generation == self.summaryPublishGeneration else { return } + handler?(summary) + } + } + + private func normalized( + _ attributes: [NSAttributedString.Key: Any], + pointSize: CGFloat + ) -> [NSAttributedString.Key: Any] { + var normalized = attributes + normalized.removeValue(forKey: .backgroundColor) + guard let highlightedFont = attributes[.font] as? UIFont else { + normalized[.font] = UIFont.monospacedSystemFont(ofSize: pointSize, weight: .regular) + return normalized + } + + let traits = highlightedFont.fontDescriptor.symbolicTraits + let weight: UIFont.Weight = traits.contains(.traitBold) ? .bold : .regular + let baseFont = UIFont.monospacedSystemFont(ofSize: pointSize, weight: weight) + if traits.contains(.traitItalic), + let descriptor = baseFont.fontDescriptor.withSymbolicTraits( + baseFont.fontDescriptor.symbolicTraits.union(.traitItalic) + ) { + normalized[.font] = UIFont(descriptor: descriptor, size: pointSize) + } else { + normalized[.font] = baseFont + } + return normalized + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactUIKitTextView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactUIKitTextView.swift new file mode 100644 index 000000000000..2383fbd4809f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactUIKitTextView.swift @@ -0,0 +1,34 @@ +#if canImport(UIKit) +import UIKit + +/// Reports UIKit/TextKit layout passes without owning artifact scroll state. +@MainActor +final class ChatArtifactUIKitTextView: UITextView { + var onLayoutDidChange: (() -> Void)? + + override var contentSize: CGSize { + didSet { + guard oldValue != contentSize else { return } + onLayoutDidChange?() + } + } + + init() { + let textStorage = NSTextStorage() + let layoutManager = NSLayoutManager() + let textContainer = NSTextContainer(size: .zero) + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + super.init(frame: .zero, textContainer: textContainer) + } + + required init?(coder: NSCoder) { + nil + } + + override func layoutSubviews() { + super.layoutSubviews() + onLayoutDidChange?() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerDestination.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerDestination.swift new file mode 100644 index 000000000000..e6f8ace14e9b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerDestination.swift @@ -0,0 +1,38 @@ +import CmuxAgentChat +import SwiftUI + +/// The shared inline navigation destination for a Mac-hosted artifact path. +public struct ChatArtifactViewerDestination: View { + private let path: String + private let scope: ChatArtifactViewerScope + private let swipeOrder: ChatArtifactGallerySwipeOrder + private let onDone: () -> Void + + /// Creates an inline artifact viewer for an existing navigation stack. + /// + /// - Parameters: + /// - path: Initially selected artifact path. + /// - scope: Authorization and navigation context for the artifact. + /// - swipeOrder: Visible gallery-file order available for horizontal paging. + /// - onDone: Dismisses or pops the navigation container that owns the destination. + public init( + path: String, + scope: ChatArtifactViewerScope = .chat, + swipeOrder: ChatArtifactGallerySwipeOrder = ChatArtifactGallerySwipeOrder(items: []), + onDone: @escaping () -> Void + ) { + self.path = path + self.scope = scope + self.swipeOrder = swipeOrder + self.onDone = onDone + } + + public var body: some View { + ChatArtifactViewerPager( + initialPath: path, + scope: scope, + swipeOrder: swipeOrder, + onDone: onDone + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift new file mode 100644 index 000000000000..144b7fbd07db --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerFileActionState.swift @@ -0,0 +1,8 @@ +/// Immutable system-presentation state owned by one artifact page. +struct ChatArtifactViewerFileActionState: Equatable { + #if os(iOS) + var presentation: ChatArtifactFileActionPresentation? = nil + #endif + var isRunning = false + var showsError = false +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerHostedPage.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerHostedPage.swift new file mode 100644 index 000000000000..57071f51751c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerHostedPage.swift @@ -0,0 +1,37 @@ +#if os(iOS) +import QuickLook +import SwiftUI + +/// Gives one path one stable SwiftUI hosting root inside the UIKit pager. +struct ChatArtifactViewerHostedPage: View { + let model: ChatArtifactViewerPageModel + let scope: ChatArtifactViewerScope + let loader: ChatArtifactLoader + let onImageMinimumZoomChanged: (String, Bool) -> Void + let onDone: () -> Void + + var path: String { model.path } + + var body: some View { + let snapshot = model.snapshot + ChatArtifactViewerRouteView( + snapshot: snapshot, + scope: scope, + actions: model.actions( + loader: loader, + quickLookCanPreview: { fileURL in + QLPreviewController.canPreview(ChatArtifactQuickLookItem( + fileURL: fileURL, + title: snapshot.displayName + )) + } + ), + onImageMinimumZoomChanged: { + onImageMinimumZoomChanged(snapshot.path, $0) + }, + onDone: onDone + ) + .clipped() + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift new file mode 100644 index 000000000000..5eb652cc1af6 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerModel.swift @@ -0,0 +1,339 @@ +import CmuxAgentChat +import Foundation +import Observation + +/// Main-actor state machine for one progressively loaded artifact path. +@Observable +@MainActor +final class ChatArtifactViewerModel { + private(set) var state: ChatArtifactViewerState = .loading + private(set) var textChunks: [String] = [] + private(set) var fetchedBytes: Int64 = 0 + private(set) var totalBytes: Int64? + private(set) var textReachedEOF = false + private(set) var activePath: String? + private(set) var activeStat: ChatArtifactStat? + private(set) var markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) + private(set) var textHighlightDecision: ChatArtifactHighlightDecision = .skippedNoLanguage + private(set) var textLineIndex = ChatArtifactLineIndex() + private let temporaryFileStore: ChatArtifactTemporaryFileStore + private var temporaryFileURL: URL? + + init( + temporaryFileStore: ChatArtifactTemporaryFileStore = ChatArtifactTemporaryFileStore() + ) { + self.temporaryFileStore = temporaryFileStore + } + + var renderedText: String { + textChunks.joined() + } + + var hasFileActions: Bool { + guard let activeStat, !activeStat.isDirectory else { return false } + return state != .loading + } + + var isTextFile: Bool { + state == .text || state == .markdown + } + + var canCopyContents: Bool { + isTextFile + && textReachedEOF + && (activeStat?.size ?? .max) <= Self.maximumCopyContentsBytes + } + + func load( + path: String, + loader: ChatArtifactLoader, + quickLookCanPreview: @MainActor (URL) -> Bool = { _ in false } + ) async { + await removeTemporaryFile() + reset(for: path) + var stat: ChatArtifactStat? + do { + let loadedStat = try await loader.stat(path: path) + try Task.checkCancellation() + guard path == activePath else { return } + stat = loadedStat + activeStat = loadedStat + totalBytes = loadedStat.size + textHighlightDecision = ChatArtifactSyntaxHighlightPolicy().decision( + path: path, + byteCount: loadedStat.size + ) + + let route = ChatArtifactPreviewRouter().route(stat: loadedStat, path: path) + guard route != .folder else { + state = loadedStat.showsFolder( + supportsDirectoryBrowsing: loader.supportsDirectoryBrowsing + ) ? .folder : .binary(stat: loadedStat) + return + } + + let policy = ChatArtifactTransferPolicy.defaultPolicy + let limit = route == .media + ? policy.maxMediaPreviewBytes + : policy.maxPreviewBytes + guard loadedStat.size <= limit else { + state = .tooLarge(actualSize: loadedStat.size, limit: limit) + return + } + + switch route { + case .text: + try await streamText( + path: path, + stat: loadedStat, + isMarkdown: false, + loader: loader + ) + case .markdown: + markdownPresentation = ChatArtifactMarkdownPresentation( + byteCount: loadedStat.size + ) + try await streamText( + path: path, + stat: loadedStat, + isMarkdown: true, + loader: loader + ) + case .image: + try await loadImage(path: path, stat: loadedStat, loader: loader) + case .pdf: + if let fileURL = try await loadTemporaryFile( + path: path, + expectedSize: loadedStat.size, + modifiedAt: loadedStat.modifiedAt, + limit: limit, + fallbackExtension: "pdf", + loader: loader + ) { + state = .pdf(fileURL: fileURL) + } + case .media: + if let fileURL = try await loadTemporaryFile( + path: path, + expectedSize: loadedStat.size, + modifiedAt: loadedStat.modifiedAt, + limit: limit, + fallbackExtension: ChatArtifactPreviewRouter() + .preferredExtension(forMIMEType: loadedStat.mimeType), + loader: loader + ) { + state = .media(fileURL: fileURL) + } + case .quickLook: + if let fileURL = try await loadTemporaryFile( + path: path, + expectedSize: loadedStat.size, + modifiedAt: loadedStat.modifiedAt, + limit: limit, + fallbackExtension: nil, + loader: loader + ) { + if quickLookCanPreview(fileURL) { + state = .quickLook(fileURL: fileURL) + } else { + await removeTemporaryFile() + state = .binary(stat: loadedStat) + } + } + case .binary: + state = .binary(stat: loadedStat) + case .folder: + break + } + } catch is CancellationError { + return + } catch is UTF8ChunkAssemblerError { + guard path == activePath, let stat else { return } + textChunks = [] + state = .binary(stat: stat) + } catch { + guard !Task.isCancelled, path == activePath else { return } + state = Self.state(for: error, stat: stat) + } + } + + func cleanup() async { + await removeTemporaryFile() + } + + func selectMarkdownMode(_ mode: ChatArtifactMarkdownMode) { + markdownPresentation.select(mode) + } + + private func reset(for path: String) { + activePath = path + activeStat = nil + state = .loading + textChunks = [] + fetchedBytes = 0 + totalBytes = nil + textReachedEOF = false + markdownPresentation = ChatArtifactMarkdownPresentation(byteCount: 0) + textHighlightDecision = .skippedNoLanguage + textLineIndex = ChatArtifactLineIndex() + } + + private func streamText( + path: String, + stat: ChatArtifactStat, + isMarkdown: Bool, + loader: ChatArtifactLoader + ) async throws { + let decoder = UTF8ChunkDecoder() + try await loader.stream( + path: path, + modifiedAt: stat.modifiedAt, + size: stat.size + ) { chunk in + try Task.checkCancellation() + let decodedBatches = try await decoder.decodeBatches(chunk.data, eof: chunk.eof) + try Task.checkCancellation() + if decodedBatches.isEmpty { + await self.receiveText( + "", + chunk: chunk, + path: path, + isMarkdown: isMarkdown, + isFinalBatch: true + ) + } else { + for (index, decoded) in decodedBatches.enumerated() { + try Task.checkCancellation() + let isFinalBatch = index == decodedBatches.index(before: decodedBatches.endIndex) + let lineIndexBatch = await Task.detached(priority: .userInitiated) { + ChatArtifactLineIndexBatch(text: decoded) + }.value + try Task.checkCancellation() + await self.receiveText( + decoded, + lineIndexBatch: lineIndexBatch, + chunk: chunk, + path: path, + isMarkdown: isMarkdown, + isFinalBatch: isFinalBatch + ) + if !isFinalBatch { + await Task.yield() + } + } + } + } + } + + private func receiveText( + _ text: String, + lineIndexBatch: ChatArtifactLineIndexBatch? = nil, + chunk: ChatArtifactChunk, + path: String, + isMarkdown: Bool, + isFinalBatch: Bool + ) { + guard path == activePath else { return } + if !text.isEmpty { + textChunks.append(text) + if let lineIndexBatch { + textLineIndex.append(lineIndexBatch) + } else { + textLineIndex.append(text) + } + } + if isFinalBatch { + updateProgress(for: chunk) + textReachedEOF = chunk.eof + } + state = isMarkdown ? .markdown : .text + } + + private func loadImage( + path: String, + stat: ChatArtifactStat, + loader: ChatArtifactLoader + ) async throws { + let accumulator = ChatArtifactDataAccumulator() + try await loader.stream( + path: path, + modifiedAt: stat.modifiedAt, + size: stat.size + ) { chunk in + try Task.checkCancellation() + await accumulator.append(chunk.data, totalSize: chunk.totalSize) + await self.receiveNonTextProgress(chunk: chunk, path: path) + } + try Task.checkCancellation() + guard path == activePath else { return } + let data = await accumulator.value() + state = .image(data: data) + } + + private func loadTemporaryFile( + path: String, + expectedSize: Int64, + modifiedAt: Date?, + limit: Int64, + fallbackExtension: String?, + loader: ChatArtifactLoader + ) async throws -> URL? { + let fileURL = try await temporaryFileStore.fetch( + path: path, + expectedSize: expectedSize, + modifiedAt: modifiedAt, + limit: limit, + fallbackExtension: fallbackExtension, + loader: loader + ) { chunk in + await self.receiveNonTextProgress(chunk: chunk, path: path) + } + guard path == activePath else { + await temporaryFileStore.remove(fileURL) + return nil + } + temporaryFileURL = fileURL + return fileURL + } + + private func receiveNonTextProgress(chunk: ChatArtifactChunk, path: String) { + guard path == activePath else { return } + updateProgress(for: chunk) + } + + private func updateProgress(for chunk: ChatArtifactChunk) { + totalBytes = chunk.totalSize + fetchedBytes = chunk.eof + ? chunk.totalSize + : chunk.offset + Int64(chunk.data.count) + } + + private func removeTemporaryFile() async { + guard let temporaryFileURL else { return } + self.temporaryFileURL = nil + await temporaryFileStore.remove(temporaryFileURL) + } + + private static func state( + for error: any Error, + stat: ChatArtifactStat? + ) -> ChatArtifactViewerState { + guard let artifactError = error as? ChatArtifactError else { + return .macUnreachable + } + switch artifactError { + case .fileNotFound: + return .fileMissing + case .forbidden: + return .forbidden + case .macUnreachable, .unavailable, .unsupported, .sessionNotFound, .invalidParams: + return .macUnreachable + case .unsupportedMedia: + return .unsupportedMedia + case .tooLarge(let limitBytes): + return .tooLarge(actualSize: stat?.size, limit: limitBytes) + } + } + + private static let maximumCopyContentsBytes: Int64 = 4 * 1024 * 1024 +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageActions.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageActions.swift new file mode 100644 index 000000000000..734b5f1f12d7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageActions.swift @@ -0,0 +1,17 @@ +import Foundation + +/// Closure actions let a viewer page mutate its owner without retaining an observable model. +struct ChatArtifactViewerPageActions { + let load: @MainActor () async -> Void + let cleanup: @MainActor () async -> Void + let retry: @MainActor () -> Void + let setSearchQuery: @MainActor (String) -> Void + let setSearchSummary: @MainActor (ChatArtifactSearchSummary) -> Void + let selectPreviousSearchResult: @MainActor () -> Void + let selectNextSearchResult: @MainActor () -> Void + let dismissSearch: @MainActor () -> Void + let setGoToLineText: @MainActor (String) -> Void + let goToLine: @MainActor (Int) -> Void + let dismissGoToLine: @MainActor () -> Void + let setFontSize: @MainActor (Double) -> Void +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift new file mode 100644 index 000000000000..12414285efd5 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageModel.swift @@ -0,0 +1,245 @@ +import CmuxAgentChat +import Foundation +import Observation + +/// Owns mutable content and controls for one stable artifact path. +@Observable +@MainActor +final class ChatArtifactViewerPageModel { + let path: String + + private let viewerModel: ChatArtifactViewerModel + private let textPreferences: ChatArtifactTextPreferences + private let textLayoutKind: ChatArtifactTextLayoutKind + private(set) var retryGeneration = 0 + private(set) var topRequestID = 0 + private(set) var bottomRequestID = 0 + private(set) var isSearchPresented = false + private(set) var searchQuery = "" + private(set) var searchSummary = ChatArtifactSearchSummary.empty + private(set) var previousSearchRequestID = 0 + private(set) var nextSearchRequestID = 0 + private(set) var showsLineNumbers = true + private(set) var isGoToLinePresented = false + private(set) var goToLineText = "" + private(set) var goToLineUTF16Offset = 0 + private(set) var goToLineRequestID = 0 + private(set) var wrapsLines: Bool + private(set) var textFontSize: Double + private(set) var fileActionState = ChatArtifactViewerFileActionState() + + init( + path: String, + textPreferences: ChatArtifactTextPreferences, + viewerModel: ChatArtifactViewerModel = ChatArtifactViewerModel() + ) { + self.path = path + self.textPreferences = textPreferences + self.viewerModel = viewerModel + let layoutKind = ChatArtifactTextLayoutKind(path: path) + textLayoutKind = layoutKind + wrapsLines = textPreferences.wrapsLines(for: layoutKind) + textFontSize = textPreferences.fontSize(for: layoutKind) + } + + var snapshot: ChatArtifactViewerPageSnapshot { + ChatArtifactViewerPageSnapshot( + path: path, + state: viewerModel.state, + textChunks: viewerModel.textChunks, + fetchedBytes: viewerModel.fetchedBytes, + totalBytes: viewerModel.totalBytes, + textReachedEOF: viewerModel.textReachedEOF, + markdownPresentation: viewerModel.markdownPresentation, + textHighlightDecision: viewerModel.textHighlightDecision, + textLineIndex: viewerModel.textLineIndex, + hasFileActions: viewerModel.hasFileActions, + isTextFile: viewerModel.isTextFile, + canCopyContents: viewerModel.canCopyContents, + retryGeneration: retryGeneration, + topRequestID: topRequestID, + bottomRequestID: bottomRequestID, + isSearchPresented: isSearchPresented, + searchQuery: searchQuery, + searchSummary: searchSummary, + previousSearchRequestID: previousSearchRequestID, + nextSearchRequestID: nextSearchRequestID, + showsLineNumbers: showsLineNumbers, + isGoToLinePresented: isGoToLinePresented, + goToLineText: goToLineText, + goToLineUTF16Offset: goToLineUTF16Offset, + goToLineRequestID: goToLineRequestID, + wrapsLines: wrapsLines, + textFontSize: textFontSize, + fileActionState: fileActionState + ) + } + + func load( + loader: ChatArtifactLoader, + quickLookCanPreview: @MainActor (URL) -> Bool + ) async { + await viewerModel.load( + path: path, + loader: loader, + quickLookCanPreview: quickLookCanPreview + ) + } + + func cleanup() async { + await viewerModel.cleanup() + } + + func actions( + loader: ChatArtifactLoader, + quickLookCanPreview: @escaping @MainActor (URL) -> Bool + ) -> ChatArtifactViewerPageActions { + ChatArtifactViewerPageActions( + load: { + await self.load( + loader: loader, + quickLookCanPreview: quickLookCanPreview + ) + }, + cleanup: { await self.cleanup() }, + retry: { self.retry() }, + setSearchQuery: { self.setSearchQuery($0) }, + setSearchSummary: { self.setSearchSummary($0) }, + selectPreviousSearchResult: { self.selectPreviousSearchResult() }, + selectNextSearchResult: { self.selectNextSearchResult() }, + dismissSearch: { self.dismissSearch() }, + setGoToLineText: { self.setGoToLineText($0) }, + goToLine: { self.goToLine($0) }, + dismissGoToLine: { self.dismissGoToLine() }, + setFontSize: { self.setFontSize($0) } + ) + } + + func retry() { + retryGeneration += 1 + } + + func toggleSearch() { + if isSearchPresented { + dismissSearch() + } else { + dismissGoToLine() + isSearchPresented = true + } + } + + func dismissSearch() { + isSearchPresented = false + searchQuery = "" + searchSummary = .empty + } + + func setSearchQuery(_ query: String) { + searchQuery = query + } + + func setSearchSummary(_ summary: ChatArtifactSearchSummary) { + searchSummary = summary + } + + func selectPreviousSearchResult() { + previousSearchRequestID += 1 + } + + func selectNextSearchResult() { + nextSearchRequestID += 1 + } + + func toggleGoToLine() { + if isGoToLinePresented { + dismissGoToLine() + } else { + dismissSearch() + isGoToLinePresented = true + } + } + + func dismissGoToLine() { + isGoToLinePresented = false + goToLineText = "" + } + + func setGoToLineText(_ text: String) { + goToLineText = text + } + + func goToLine(_ requestedLine: Int) { + let line = viewerModel.textLineIndex.clampedLine(requestedLine) + goToLineText = String(line) + goToLineUTF16Offset = viewerModel.textLineIndex.offset(forLine: line) + goToLineRequestID += 1 + } + + func requestTop() { + topRequestID += 1 + } + + func requestBottom() { + bottomRequestID += 1 + } + + func toggleLineNumbers() { + showsLineNumbers.toggle() + } + + func toggleWordWrap() { + wrapsLines.toggle() + textPreferences.setWrapsLines(wrapsLines, for: textLayoutKind) + } + + func setFontSize(_ fontSize: Double) { + textFontSize = textPreferences.setFontSize(fontSize, for: textLayoutKind) + } + + func selectMarkdownMode(_ mode: ChatArtifactMarkdownMode) { + if mode == .rendered { + dismissSearch() + dismissGoToLine() + } + viewerModel.selectMarkdownMode(mode) + } + + #if os(iOS) + func prepareShare(loader: ChatArtifactLoader) async { + await prepareFileAction(loader: loader, presentation: ChatArtifactFileActionPresentation.share) + } + + func prepareSave(loader: ChatArtifactLoader) async { + await prepareFileAction(loader: loader, presentation: ChatArtifactFileActionPresentation.save) + } + + func setFileActionPresentation(_ presentation: ChatArtifactFileActionPresentation?) { + fileActionState.presentation = presentation + } + + func setShowsFileActionError(_ isPresented: Bool) { + fileActionState.showsError = isPresented + } + + private func prepareFileAction( + loader: ChatArtifactLoader, + presentation: (URL) -> ChatArtifactFileActionPresentation + ) async { + guard !fileActionState.isRunning else { return } + fileActionState.isRunning = true + defer { fileActionState.isRunning = false } + do { + let fileURL = try await ChatArtifactFileActionStore.applicationDefault.materialize( + path: path, + loader: loader + ) + try Task.checkCancellation() + fileActionState.presentation = presentation(fileURL) + } catch is CancellationError { + return + } catch { + fileActionState.showsError = true + } + } + #endif +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift new file mode 100644 index 000000000000..1227b4b4bad8 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPageSnapshot.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Immutable render state for one path-keyed artifact viewer page. +struct ChatArtifactViewerPageSnapshot: Identifiable, Equatable { + let path: String + let state: ChatArtifactViewerState + let textChunks: [String] + let fetchedBytes: Int64 + let totalBytes: Int64? + let textReachedEOF: Bool + let markdownPresentation: ChatArtifactMarkdownPresentation + let textHighlightDecision: ChatArtifactHighlightDecision + let textLineIndex: ChatArtifactLineIndex + let hasFileActions: Bool + let isTextFile: Bool + let canCopyContents: Bool + let retryGeneration: Int + let topRequestID: Int + let bottomRequestID: Int + let isSearchPresented: Bool + let searchQuery: String + let searchSummary: ChatArtifactSearchSummary + let previousSearchRequestID: Int + let nextSearchRequestID: Int + let showsLineNumbers: Bool + let isGoToLinePresented: Bool + let goToLineText: String + let goToLineUTF16Offset: Int + let goToLineRequestID: Int + let wrapsLines: Bool + let textFontSize: Double + let fileActionState: ChatArtifactViewerFileActionState + + var id: String { path } + + var displayName: String { + URL(fileURLWithPath: path).lastPathComponent + } + + var renderedText: String { + textChunks.joined() + } + + var shouldShowTextJumpControls: Bool { + state == .text + || (state == .markdown && markdownPresentation.mode == .raw) + } + + var hasViewerActions: Bool { + hasFileActions + || shouldShowTextJumpControls + || (state == .markdown && markdownPresentation.isRenderedAvailable) + } + + var showsHighlightingStatusPill: Bool { + shouldShowTextJumpControls + && textHighlightDecision.showsHighlightingOffPill + && totalBytes != nil + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift new file mode 100644 index 000000000000..9b1aae10a66c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPager.swift @@ -0,0 +1,379 @@ +import CmuxAgentChat +import SwiftUI + +#if os(iOS) +import QuickLook +import UIKit +#endif + +/// Owns path-stable viewer pages and the destination's only navigation toolbar. +struct ChatArtifactViewerPager: View { + let initialPath: String + let scope: ChatArtifactViewerScope + let swipeOrder: ChatArtifactGallerySwipeOrder + let onDone: () -> Void + + @Environment(\.chatArtifactLoader) private var loader + @State private var model: ChatArtifactViewerPagerModel + @State private var zoomedPath: String? + + init( + initialPath: String, + scope: ChatArtifactViewerScope, + swipeOrder: ChatArtifactGallerySwipeOrder, + onDone: @escaping () -> Void + ) { + self.initialPath = initialPath + self.scope = scope + self.swipeOrder = swipeOrder + self.onDone = onDone + _model = State(initialValue: ChatArtifactViewerPagerModel( + initialPath: initialPath, + swipeOrder: swipeOrder, + textPreferences: ChatArtifactTextPreferences(defaults: .standard) + )) + } + + @ViewBuilder + var body: some View { + pagerContent + .navigationTitle(model.toolbarSnapshot.displayName) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + #endif + .toolbar { + #if os(iOS) + ToolbarItemGroup(placement: .topBarTrailing) { + if model.toolbarSnapshot.hasViewerActions { + viewerActionsMenu(snapshot: model.toolbarSnapshot) + } + doneButton + } + #else + ToolbarItem(placement: .cancellationAction) { + doneButton + } + #endif + } + #if os(iOS) + .chatArtifactFileActionPresentation(fileActionPresentationBinding) + .alert( + String( + localized: "chat.artifact.action_failed.title", + defaultValue: "Couldn't complete action", + bundle: .module + ), + isPresented: fileActionErrorBinding + ) { + Button(String(localized: "chat.artifact.ok", defaultValue: "OK", bundle: .module)) {} + } message: { + Text(String( + localized: "chat.artifact.action_failed.message", + defaultValue: "Check the connection to your Mac and try again.", + bundle: .module + )) + } + #endif + .onChange(of: initialPath) { _, newPath in + model.update(initialPath: newPath, swipeOrder: swipeOrder) + } + .onChange(of: swipeOrder) { _, newOrder in + model.update(swipeOrder: newOrder) + } + } + + @ViewBuilder + private var pagerContent: some View { + #if os(iOS) + if model.usesPaging { + ChatArtifactPageViewController( + pages: model.pageModels.map(hostedPage), + selectedPath: selectionBinding, + isPagingEnabled: zoomedPath == nil + ) + } else { + viewer(snapshot: model.toolbarSnapshot) + .id(model.toolbarSnapshot.path) + } + #else + viewer(snapshot: model.toolbarSnapshot) + .id(model.toolbarSnapshot.path) + #endif + } + + #if os(iOS) + private func hostedPage(model: ChatArtifactViewerPageModel) -> ChatArtifactViewerHostedPage { + ChatArtifactViewerHostedPage( + model: model, + scope: scope, + loader: loader, + onImageMinimumZoomChanged: { path, isAtMinimum in + if isAtMinimum { + if zoomedPath == path { + zoomedPath = nil + } + } else { + zoomedPath = path + } + }, + onDone: onDone + ) + } + #endif + + private func viewer(snapshot: ChatArtifactViewerPageSnapshot) -> some View { + ChatArtifactViewerRouteView( + snapshot: snapshot, + scope: scope, + actions: model.actions( + for: snapshot.path, + loader: loader, + quickLookCanPreview: { fileURL in + #if os(iOS) + QLPreviewController.canPreview(ChatArtifactQuickLookItem( + fileURL: fileURL, + title: snapshot.displayName + )) + #else + false + #endif + } + ), + onImageMinimumZoomChanged: { isAtMinimum in + if isAtMinimum { + if zoomedPath == snapshot.path { + zoomedPath = nil + } + } else { + zoomedPath = snapshot.path + } + }, + onDone: onDone + ) + } + + private var selectionBinding: Binding { + Binding( + get: { model.selectedPath }, + set: { model.select(path: $0) } + ) + } + + private var doneButton: some View { + Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { + onDone() + } + } + + #if os(iOS) + private func viewerActionsMenu(snapshot: ChatArtifactViewerPageSnapshot) -> some View { + Menu { + if snapshot.hasFileActions { + Section { + fileActionButtons(snapshot: snapshot) + } + } + if snapshot.shouldShowTextJumpControls { + Section { + textViewerActionButtons(snapshot: snapshot) + } + } + if snapshot.state == .markdown, + snapshot.markdownPresentation.isRenderedAvailable { + Section { + Picker( + String( + localized: "chat.artifact.markdown.view", + defaultValue: "Markdown view", + bundle: .module + ), + selection: markdownModeBinding + ) { + Text(String( + localized: "chat.artifact.markdown.raw", + defaultValue: "Raw", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.raw) + Text(String( + localized: "chat.artifact.markdown.rendered", + defaultValue: "Rendered", + bundle: .module + )) + .tag(ChatArtifactMarkdownMode.rendered) + } + } + } + } label: { + Label( + String( + localized: "chat.artifact.viewer.actions", + defaultValue: "Viewer actions", + bundle: .module + ), + systemImage: "ellipsis.circle" + ) + } + .disabled(snapshot.fileActionState.isRunning) + } + + @ViewBuilder + private func fileActionButtons(snapshot: ChatArtifactViewerPageSnapshot) -> some View { + Button { + Task { await model.prepareShare(loader: loader) } + } label: { + Label( + String(localized: "chat.artifact.share", defaultValue: "Share", bundle: .module), + systemImage: "square.and.arrow.up" + ) + } + Button { + Task { await model.prepareSave(loader: loader) } + } label: { + Label( + String(localized: "chat.artifact.save_to_files", defaultValue: "Save to Files", bundle: .module), + systemImage: "folder.badge.plus" + ) + } + if snapshot.isTextFile { + Button { + UIPasteboard.general.string = snapshot.renderedText + } label: { + Label( + String(localized: "chat.artifact.copy_contents", defaultValue: "Copy contents", bundle: .module), + systemImage: "doc.on.doc" + ) + } + .disabled(!snapshot.canCopyContents) + } + Button { + UIPasteboard.general.string = snapshot.path + } label: { + Label( + String(localized: "chat.artifact.copy_path", defaultValue: "Copy path", bundle: .module), + systemImage: "link" + ) + } + } + + @ViewBuilder + private func textViewerActionButtons(snapshot: ChatArtifactViewerPageSnapshot) -> some View { + Button { + withAnimation(.snappy) { + model.toggleSearch() + } + } label: { + Label( + String( + localized: "chat.artifact.search.title", + defaultValue: "Search", + bundle: .module + ), + systemImage: "magnifyingglass" + ) + } + Button { + withAnimation(.snappy) { + model.toggleGoToLine() + } + } label: { + Label( + String( + localized: "chat.artifact.line.goto", + defaultValue: "Go to line", + bundle: .module + ), + systemImage: "text.line.first.and.arrowtriangle.forward" + ) + } + Button { + model.requestTop() + } label: { + Label( + String( + localized: "chat.artifact.jump.top", + defaultValue: "Top", + bundle: .module + ), + systemImage: "arrow.up.to.line" + ) + } + Button { + model.requestBottom() + } label: { + Label(jumpToEndTitle(snapshot: snapshot), systemImage: "arrow.down.to.line") + } + Button { + model.toggleLineNumbers() + } label: { + Label( + String( + localized: "chat.artifact.line.numbers", + defaultValue: "Line numbers", + bundle: .module + ), + systemImage: snapshot.showsLineNumbers ? "checkmark" : "number" + ) + } + Button { + model.toggleWordWrap() + } label: { + Label( + String( + localized: "chat.artifact.wrap", + defaultValue: "Word wrap", + bundle: .module + ), + systemImage: snapshot.wrapsLines ? "checkmark" : "text.justify.left" + ) + } + } + + private var markdownModeBinding: Binding { + Binding( + get: { model.toolbarSnapshot.markdownPresentation.mode }, + set: { model.selectMarkdownMode($0) } + ) + } + + private var fileActionPresentationBinding: Binding { + let path = model.toolbarSnapshot.path + return Binding( + get: { + model.toolbarSnapshot.path == path + ? model.toolbarSnapshot.fileActionState.presentation + : nil + }, + set: { model.setFileActionPresentation($0, for: path) } + ) + } + + private var fileActionErrorBinding: Binding { + let path = model.toolbarSnapshot.path + return Binding( + get: { + model.toolbarSnapshot.path == path + && model.toolbarSnapshot.fileActionState.showsError + }, + set: { model.setShowsFileActionError($0, for: path) } + ) + } + + private func jumpToEndTitle(snapshot: ChatArtifactViewerPageSnapshot) -> String { + switch ChatArtifactTextEndJumpTarget(reachedEOF: snapshot.textReachedEOF) { + case .end: + return String( + localized: "chat.artifact.jump.end", + defaultValue: "End", + bundle: .module + ) + case .latest: + return String( + localized: "chat.artifact.jump.latest", + defaultValue: "Latest", + bundle: .module + ) + } + } + #endif +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift new file mode 100644 index 000000000000..7c51f876960d --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPagerModel.swift @@ -0,0 +1,165 @@ +import CmuxAgentChat +import Foundation +import Observation + +/// Retains one page owner per visible path while projecting a single selected toolbar state. +@Observable +@MainActor +final class ChatArtifactViewerPagerModel { + private(set) var selectedPath: String + private(set) var swipeOrder: ChatArtifactGallerySwipeOrder + private let textPreferences: ChatArtifactTextPreferences + @ObservationIgnored private var selectedPageModel: ChatArtifactViewerPageModel + @ObservationIgnored + private var pagesByPath: [String: ChatArtifactViewerPageModel] = [:] + + init( + initialPath: String, + swipeOrder: ChatArtifactGallerySwipeOrder, + textPreferences: ChatArtifactTextPreferences + ) { + selectedPath = initialPath + self.swipeOrder = swipeOrder + self.textPreferences = textPreferences + let initialPage = ChatArtifactViewerPageModel( + path: initialPath, + textPreferences: textPreferences + ) + selectedPageModel = initialPage + pagesByPath[initialPath] = initialPage + reconcilePages() + } + + var pageSnapshots: [ChatArtifactViewerPageSnapshot] { + pagePaths.compactMap { pagesByPath[$0]?.snapshot } + } + + var pageModels: [ChatArtifactViewerPageModel] { + pagePaths.compactMap { pagesByPath[$0] } + } + + var toolbarSnapshot: ChatArtifactViewerPageSnapshot { + selectedPageModel.snapshot + } + + var usesPaging: Bool { + swipeOrder.count > 1 && swipeOrder.paths.contains(selectedPath) + } + + func select(path: String) { + guard path != selectedPath, swipeOrder.paths.contains(path) else { return } + selectedPageModel = page(for: path) + selectedPath = path + reconcilePages() + } + + func update( + initialPath: String? = nil, + swipeOrder: ChatArtifactGallerySwipeOrder + ) { + self.swipeOrder = swipeOrder + if let initialPath, initialPath != selectedPath { + selectedPageModel = page(for: initialPath) + selectedPath = initialPath + } + reconcilePages() + } + + func pageIdentity(for path: String) -> ObjectIdentifier? { + pagesByPath[path].map(ObjectIdentifier.init) + } + + func actions( + for path: String, + loader: ChatArtifactLoader, + quickLookCanPreview: @escaping @MainActor (URL) -> Bool + ) -> ChatArtifactViewerPageActions { + let page = path == selectedPath + ? selectedPageModel + : pagesByPath[path]! + return page.actions( + loader: loader, + quickLookCanPreview: quickLookCanPreview + ) + } + + func toggleSearch() { + selectedPage.toggleSearch() + } + + func toggleGoToLine() { + selectedPage.toggleGoToLine() + } + + func requestTop() { + selectedPage.requestTop() + } + + func requestBottom() { + selectedPage.requestBottom() + } + + func toggleLineNumbers() { + selectedPage.toggleLineNumbers() + } + + func toggleWordWrap() { + selectedPage.toggleWordWrap() + } + + func selectMarkdownMode(_ mode: ChatArtifactMarkdownMode) { + selectedPage.selectMarkdownMode(mode) + } + + #if os(iOS) + func prepareShare(loader: ChatArtifactLoader) async { + let page = selectedPage + await page.prepareShare(loader: loader) + } + + func prepareSave(loader: ChatArtifactLoader) async { + let page = selectedPage + await page.prepareSave(loader: loader) + } + + func setFileActionPresentation( + _ presentation: ChatArtifactFileActionPresentation?, + for path: String + ) { + pagesByPath[path]?.setFileActionPresentation(presentation) + } + + func setShowsFileActionError(_ isPresented: Bool, for path: String) { + pagesByPath[path]?.setShowsFileActionError(isPresented) + } + #endif + + private var selectedPage: ChatArtifactViewerPageModel { + selectedPageModel + } + + private var pagePaths: [String] { + guard swipeOrder.paths.contains(selectedPath) else { return [selectedPath] } + return swipeOrder.pageWindow(around: selectedPath).map(\.path) + } + + private func page(for path: String) -> ChatArtifactViewerPageModel { + if let page = pagesByPath[path] { + return page + } + let page = ChatArtifactViewerPageModel( + path: path, + textPreferences: textPreferences + ) + pagesByPath[path] = page + return page + } + + private func reconcilePages() { + var nextPages: [String: ChatArtifactViewerPageModel] = [:] + for path in pagePaths { + nextPages[path] = page(for: path) + } + pagesByPath = nextPages + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPresentationCoordinator.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPresentationCoordinator.swift new file mode 100644 index 000000000000..5860ec2fc91f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerPresentationCoordinator.swift @@ -0,0 +1,35 @@ +import Observation + +/// Orders viewer loading after the destination has entered the view hierarchy. +@Observable +@MainActor +final class ChatArtifactViewerPresentationCoordinator { + private(set) var isPresented = false + private(set) var generation = 0 + + /// Records a new appearance before any route-specific loader can run. + func present() { + guard !isPresented else { return } + isPresented = true + generation &+= 1 + } + + /// Records that the destination has left the view hierarchy. + func dismiss() { + isPresented = false + } + + /// Starts content resolution only after presentation has had a scheduler turn. + /// + /// - Parameter load: The shared stat, fetch, and route-resolution operation. + /// - Returns: Whether the operation started for a presented destination. + func loadAfterPresentation( + _ load: @MainActor () async -> Void + ) async -> Bool { + guard isPresented else { return false } + await Task.yield() + guard isPresented, !Task.isCancelled else { return false } + await load() + return true + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift new file mode 100644 index 000000000000..d3c28296262b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerRouteView.swift @@ -0,0 +1,443 @@ +import CmuxAgentChat +import SwiftUI + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +/// Renders one immutable artifact page snapshot without contributing navigation chrome. +struct ChatArtifactViewerRouteView: View { + let snapshot: ChatArtifactViewerPageSnapshot + let scope: ChatArtifactViewerScope + let actions: ChatArtifactViewerPageActions + let onDone: () -> Void + let onImageMinimumZoomChanged: (Bool) -> Void + + @Environment(\.colorScheme) private var colorScheme + @State private var presentation = ChatArtifactViewerPresentationCoordinator() + + init( + snapshot: ChatArtifactViewerPageSnapshot, + scope: ChatArtifactViewerScope, + actions: ChatArtifactViewerPageActions, + onImageMinimumZoomChanged: @escaping (Bool) -> Void = { _ in }, + onDone: @escaping () -> Void + ) { + self.snapshot = snapshot + self.scope = scope + self.actions = actions + self.onDone = onDone + self.onImageMinimumZoomChanged = onImageMinimumZoomChanged + } + + var body: some View { + content + .onAppear { + presentation.present() + } + .onDisappear { + presentation.dismiss() + } + .task(id: "\(path)\u{0}\(snapshot.retryGeneration)\u{0}\(presentation.generation)") { + let didStart = await presentation.loadAfterPresentation { + await actions.load() + } + guard didStart else { return } + await waitForViewerTaskCancellation() + await actions.cleanup() + } + } + + private var path: String { snapshot.path } + + /// Keeps cleanup structured under the SwiftUI page task after loading ends. + private func waitForViewerTaskCancellation() async { + let (stream, continuation) = AsyncStream.makeStream() + defer { continuation.finish() } + for await _ in stream {} + } + + @ViewBuilder + private var content: some View { + switch snapshot.state { + case .loading: + VStack(spacing: 12) { + ProgressView( + value: progressValue( + fetched: snapshot.fetchedBytes, + total: snapshot.totalBytes + ) + ) + .progressViewStyle(.linear) + .frame(maxWidth: 220) + Text(String(localized: "chat.artifact.loading", defaultValue: "Loading preview", bundle: .module)) + .font(.subheadline) + .foregroundStyle(.secondary) + if snapshot.fetchedBytes > 0 || snapshot.totalBytes != nil { + Text( + verbatim: progressText( + fetched: snapshot.fetchedBytes, + total: snapshot.totalBytes + ) + ) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + case .folder: + ChatArtifactFolderView( + path: path, + scope: scope, + onDone: onDone + ) + case .image(let data): + #if os(iOS) + if let image = UIImage(data: data) { + ChatArtifactZoomableImageView( + image: image, + onMinimumZoomChanged: onImageMinimumZoomChanged + ) + .ignoresSafeArea(.container, edges: .bottom) + .onDisappear { + onImageMinimumZoomChanged(true) + } + } else { + Color.clear + } + #else + artifactImage(data: data) + .scaledToFit() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + #endif + case .pdf(let fileURL): + #if os(iOS) + ChatArtifactPDFView(fileURL: fileURL) + .ignoresSafeArea(.container, edges: .bottom) + #else + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) + ) + #endif + case .media(let fileURL): + #if os(iOS) + ChatArtifactMediaView(fileURL: fileURL) + .ignoresSafeArea(.container, edges: .bottom) + #else + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) + ) + #endif + case .quickLook(let fileURL): + #if os(iOS) + ChatArtifactQuickLookView(fileURL: fileURL, title: snapshot.displayName) + .ignoresSafeArea(.container, edges: .bottom) + #else + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module) + ) + #endif + case .text: + VStack(spacing: 0) { + if !snapshot.textReachedEOF { + streamingProgressHeader + } + searchBar + goToLineBar + highlightingStatusPill + rawTextView + } + case .markdown: + VStack(spacing: 0) { + if !snapshot.textReachedEOF { + streamingProgressHeader + } + if snapshot.markdownPresentation.mode == .rendered { + ChatArtifactMarkdownView(markdown: snapshot.renderedText) + } else { + searchBar + goToLineBar + highlightingStatusPill + rawTextView + } + } + case .binary(let stat): + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), + detail: formattedSize(stat.size) + ) + case .tooLarge(let actualSize, let limit): + unavailableView( + title: String(localized: "chat.artifact.too_large.title", defaultValue: "File too large to preview", bundle: .module), + message: tooLargeMessage(actualSize: actualSize, limit: limit) + ) + case .unsupportedMedia: + unavailableView( + title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), + message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), + detail: nil + ) + case .fileMissing: + unavailableView( + title: String(localized: "chat.artifact.file_missing.title", defaultValue: "File not found", bundle: .module), + message: String(localized: "chat.artifact.file_missing.message", defaultValue: "The file is no longer available on your Mac.", bundle: .module), + retry: false + ) + case .macUnreachable: + unavailableView( + title: String(localized: "chat.artifact.mac_unreachable.title", defaultValue: "Mac unreachable", bundle: .module), + message: String(localized: "chat.artifact.mac_unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module), + retry: true + ) + case .forbidden: + unavailableView( + title: String(localized: "chat.artifact.forbidden.title", defaultValue: "Preview unavailable", bundle: .module), + message: forbiddenMessage, + retry: false + ) + } + } + + private var streamingProgressHeader: some View { + HStack(spacing: 10) { + ProgressView( + value: progressValue( + fetched: snapshot.fetchedBytes, + total: snapshot.totalBytes + ) + ) + .progressViewStyle(.linear) + Text(verbatim: progressText(fetched: snapshot.fetchedBytes, total: snapshot.totalBytes)) + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + .fixedSize() + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .background(.bar) + } + + @ViewBuilder + private var rawTextView: some View { + #if canImport(UIKit) + ChatArtifactTextView( + documentID: path, + chunks: snapshot.textChunks, + reachedEOF: snapshot.textReachedEOF, + highlightDecision: snapshot.textHighlightDecision, + highlightTheme: colorScheme == .dark ? .dark : .light, + searchQuery: snapshot.searchQuery, + previousSearchRequestID: snapshot.previousSearchRequestID, + nextSearchRequestID: snapshot.nextSearchRequestID, + onSearchSummaryChanged: { actions.setSearchSummary($0) }, + lineIndex: snapshot.textLineIndex, + showsLineNumbers: snapshot.showsLineNumbers, + goToLineUTF16Offset: snapshot.goToLineUTF16Offset, + goToLineRequestID: snapshot.goToLineRequestID, + wrapsLines: snapshot.wrapsLines, + fontPointSize: snapshot.textFontSize, + onFontSizeChanged: { actions.setFontSize($0) }, + topRequestID: snapshot.topRequestID, + bottomRequestID: snapshot.bottomRequestID + ) + #else + ScrollView { + Text(snapshot.renderedText) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + #endif + } + + @ViewBuilder + private var goToLineBar: some View { + if snapshot.isGoToLinePresented { + ChatArtifactGoToLineBar( + lineText: goToLineTextBinding, + onGo: { actions.goToLine($0) }, + onClose: { + withAnimation(.snappy) { + actions.dismissGoToLine() + } + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + + @ViewBuilder + private var searchBar: some View { + if snapshot.isSearchPresented { + ChatArtifactSearchBar( + query: searchQueryBinding, + summary: snapshot.searchSummary, + isStillLoading: !snapshot.textReachedEOF, + onPrevious: { actions.selectPreviousSearchResult() }, + onNext: { actions.selectNextSearchResult() }, + onClose: { + withAnimation(.snappy) { + actions.dismissSearch() + } + } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + + @ViewBuilder + private var highlightingStatusPill: some View { + if snapshot.showsHighlightingStatusPill, + let totalBytes = snapshot.totalBytes { + HStack { + Spacer(minLength: 16) + ChatArtifactHighlightingStatusPill( + actualBytes: totalBytes, + maximumBytes: ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + ) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + } + + private func unavailableView( + title: String, + message: String, + detail: String? = nil, + retry: Bool = false + ) -> some View { + VStack(spacing: 10) { + Text(title) + .font(.headline) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + if let detail { + Text(detail) + .font(.caption.monospacedDigit()) + .foregroundStyle(.tertiary) + } + if retry { + Button { + actions.retry() + } label: { + Label( + String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), + systemImage: "arrow.clockwise" + ) + } + .buttonStyle(.borderedProminent) + .padding(.top, 4) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + @ViewBuilder + private func artifactImage(data: Data) -> some View { + #if canImport(UIKit) + if let image = UIImage(data: data) { + Image(uiImage: image) + .resizable() + } else { + Color.clear + } + #elseif canImport(AppKit) + if let image = NSImage(data: data) { + Image(nsImage: image) + .resizable() + } else { + Color.clear + } + #else + Color.clear + #endif + } + + private var searchQueryBinding: Binding { + Binding( + get: { snapshot.searchQuery }, + set: { actions.setSearchQuery($0) } + ) + } + + private var goToLineTextBinding: Binding { + Binding( + get: { snapshot.goToLineText }, + set: { actions.setGoToLineText($0) } + ) + } + + private var forbiddenMessage: String { + switch scope { + case .chat: + String( + localized: "chat.artifact.forbidden.message", + defaultValue: "This file was not referenced by the conversation.", + bundle: .module + ) + case .terminal: + String( + localized: "chat.artifact.forbidden.terminal_message", + defaultValue: "This file isn't visible in the current terminal view.", + bundle: .module + ) + } + } + + private func progressValue(fetched: Int64, total: Int64?) -> Double? { + guard let total, total > 0 else { return nil } + return Double(fetched) / Double(total) + } + + private func progressText(fetched: Int64, total: Int64?) -> String { + if let total { + return "\(formattedSize(fetched)) / \(formattedSize(total))" + } + return formattedSize(fetched) + } + + private func formattedSize(_ bytes: Int64) -> String { + ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) + } + + private func tooLargeMessage(actualSize: Int64?, limit: Int64) -> String { + guard let actualSize else { + let format = String( + localized: "chat.artifact.too_large.limit_message", + defaultValue: "This preview is limited to %@.", + bundle: .module + ) + return String.localizedStringWithFormat(format, formattedSize(limit)) + } + let format = String( + localized: "chat.artifact.too_large.message", + defaultValue: "This file is %@; previews are limited to %@.", + bundle: .module + ) + return String.localizedStringWithFormat( + format, + formattedSize(actualSize), + formattedSize(limit) + ) + } +} + +extension ChatArtifactStat { + /// Whether this artifact routes to the recursive folder browser. + func showsFolder(supportsDirectoryBrowsing: Bool) -> Bool { + isDirectory && supportsDirectoryBrowsing + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift index b3d7704880ea..26e45f7960bc 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerSheet.swift @@ -1,289 +1,37 @@ import CmuxAgentChat import SwiftUI -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - +/// A navigation container for one Mac-hosted artifact path. public struct ChatArtifactViewerSheet: View { let path: String let scope: ChatArtifactViewerScope - - @Environment(\.chatArtifactLoader) private var loader + let swipeOrder: ChatArtifactGallerySwipeOrder @Environment(\.dismiss) private var dismiss - @State private var state: LoadState = .loading(fetched: 0, total: nil) - public init(path: String, scope: ChatArtifactViewerScope = .chat) { + /// Creates an artifact viewer that routes files and folders through the + /// same stat-driven navigation path. + /// - Parameters: + /// - path: Initially selected artifact path. + /// - scope: Authorization and navigation context for the artifact. + /// - swipeOrder: Visible gallery-file order available for horizontal paging. + public init( + path: String, + scope: ChatArtifactViewerScope = .chat, + swipeOrder: ChatArtifactGallerySwipeOrder = ChatArtifactGallerySwipeOrder(items: []) + ) { self.path = path self.scope = scope + self.swipeOrder = swipeOrder } public var body: some View { NavigationStack { - content - .navigationTitle(displayName) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "chat.artifact.done", defaultValue: "Done", bundle: .module)) { - dismiss() - } - } - } - } - .task(id: path) { - await load() - } - } - - @ViewBuilder - private var content: some View { - switch state { - case .loading(let fetched, let total): - VStack(spacing: 12) { - ProgressView(value: progressValue(fetched: fetched, total: total)) - .progressViewStyle(.linear) - .frame(maxWidth: 220) - Text(String(localized: "chat.artifact.loading", defaultValue: "Loading preview", bundle: .module)) - .font(.subheadline) - .foregroundStyle(.secondary) - if fetched > 0 || total != nil { - Text(verbatim: progressText(fetched: fetched, total: total)) - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - case .image(let data): - artifactImage(data: data) - .scaledToFit() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - case .text(let text): - #if canImport(UIKit) - ChatArtifactTextView(text: text) - #else - ScrollView { - Text(text) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } - #endif - case .binary(let stat): - unavailableView( - title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), - message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), - detail: formattedSize(stat.size) - ) - case .tooLarge(let limit): - unavailableView( - title: String(localized: "chat.artifact.too_large.title", defaultValue: "File too large to preview", bundle: .module), - message: tooLargeMessage(limit: limit) - ) - case .unsupportedMedia: - unavailableView( - title: String(localized: "chat.artifact.preview_unavailable.title", defaultValue: "Preview unavailable", bundle: .module), - message: String(localized: "chat.artifact.preview_unavailable.message", defaultValue: "This file can't be previewed.", bundle: .module), - detail: nil - ) - case .fileMissing: - unavailableView( - title: String(localized: "chat.artifact.file_missing.title", defaultValue: "File not found", bundle: .module), - message: String(localized: "chat.artifact.file_missing.message", defaultValue: "The file is no longer available on your Mac.", bundle: .module), - retry: false - ) - case .macUnreachable: - unavailableView( - title: String(localized: "chat.artifact.mac_unreachable.title", defaultValue: "Mac unreachable", bundle: .module), - message: String(localized: "chat.artifact.mac_unreachable.message", defaultValue: "Check the connection to your Mac and try again.", bundle: .module), - retry: true - ) - case .forbidden: - unavailableView( - title: String(localized: "chat.artifact.forbidden.title", defaultValue: "Preview unavailable", bundle: .module), - message: forbiddenMessage, - retry: false - ) - } - } - - private func load() async { - await MainActor.run { - state = .loading(fetched: 0, total: nil) - } - do { - let stat = try await loader.stat(path: path) - guard !stat.isDirectory else { - await MainActor.run { state = .binary(stat: stat) } - return - } - guard stat.size <= ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes else { - await MainActor.run { - state = .tooLarge(limit: ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes) - } - return - } - let data = try await loader.fetch(path: path) { fetched, total in - Task { @MainActor in - state = .loading(fetched: fetched, total: total) - } - } - guard !Task.isCancelled else { return } - switch stat.kind { - case .image: - await MainActor.run { state = .image(data: data) } - case .text: - if let text = String(data: data, encoding: .utf8) { - await MainActor.run { state = .text(text: text) } - } else { - await MainActor.run { state = .binary(stat: stat) } - } - case .binary, .directory: - await MainActor.run { state = .binary(stat: stat) } - } - } catch { - await MainActor.run { - state = LoadState(error: error) - } - } - } - - private func unavailableView( - title: String, - message: String, - detail: String? = nil, - retry: Bool = false - ) -> some View { - VStack(spacing: 10) { - Text(title) - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - if let detail { - Text(detail) - .font(.caption.monospacedDigit()) - .foregroundStyle(.tertiary) - } - if retry { - Button { - Task { await load() } - } label: { - Label( - String(localized: "chat.artifact.retry", defaultValue: "Retry", bundle: .module), - systemImage: "arrow.clockwise" - ) - } - .buttonStyle(.borderedProminent) - .padding(.top, 4) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding() - } - - @ViewBuilder - private func artifactImage(data: Data) -> some View { - #if canImport(UIKit) - if let image = UIImage(data: data) { - Image(uiImage: image) - .resizable() - } else { - Color.clear - } - #elseif canImport(AppKit) - if let image = NSImage(data: data) { - Image(nsImage: image) - .resizable() - } else { - Color.clear - } - #else - Color.clear - #endif - } - - private var displayName: String { - URL(fileURLWithPath: path).lastPathComponent - } - - private var forbiddenMessage: String { - switch scope { - case .chat: - String( - localized: "chat.artifact.forbidden.message", - defaultValue: "This file was not referenced by the conversation.", - bundle: .module - ) - case .terminal: - String( - localized: "chat.artifact.forbidden.terminal_message", - defaultValue: "This file isn't visible in the current terminal view.", - bundle: .module - ) - } - } - - private func progressValue(fetched: Int64, total: Int64?) -> Double? { - guard let total, total > 0 else { return nil } - return Double(fetched) / Double(total) - } - - private func progressText(fetched: Int64, total: Int64?) -> String { - if let total { - return "\(formattedSize(fetched)) / \(formattedSize(total))" - } - return formattedSize(fetched) - } - - private func formattedSize(_ bytes: Int64) -> String { - ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) - } - - private func tooLargeMessage(limit: Int64) -> String { - let format = String( - localized: "chat.artifact.too_large.message", - defaultValue: "This preview is limited to %@.", - bundle: .module - ) - return String.localizedStringWithFormat(format, formattedSize(limit)) - } - - private enum LoadState: Equatable { - case loading(fetched: Int64, total: Int64?) - case image(data: Data) - case text(text: String) - case binary(stat: ChatArtifactStat) - case tooLarge(limit: Int64) - case unsupportedMedia - case fileMissing - case macUnreachable - case forbidden - - init(error: any Error) { - guard let artifactError = error as? ChatArtifactError else { - self = .macUnreachable - return - } - switch artifactError { - case .fileNotFound: - self = .fileMissing - case .forbidden: - self = .forbidden - case .macUnreachable, .unavailable, .unsupported, .sessionNotFound, .invalidParams: - self = .macUnreachable - case .unsupportedMedia: - self = .unsupportedMedia - case .tooLarge(let limitBytes): - self = .tooLarge(limit: limitBytes) + ChatArtifactViewerDestination( + path: path, + scope: scope, + swipeOrder: swipeOrder + ) { + dismiss() } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift new file mode 100644 index 000000000000..bdf1b2767f5d --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactViewerState.swift @@ -0,0 +1,20 @@ +import CmuxAgentChat +import Foundation + +/// Renderable states for one stat-driven artifact path. +enum ChatArtifactViewerState: Equatable { + case loading + case folder + case image(data: Data) + case pdf(fileURL: URL) + case media(fileURL: URL) + case quickLook(fileURL: URL) + case text + case markdown + case binary(stat: ChatArtifactStat) + case tooLarge(actualSize: Int64?, limit: Int64) + case unsupportedMedia + case fileMissing + case macUnreachable + case forbidden +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift new file mode 100644 index 000000000000..adf875201931 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomPolicy.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Scale rules shared by artifact image zoom gestures and tests. +struct ChatArtifactZoomPolicy: Equatable, Sendable { + enum HorizontalSwipeOwner: Equatable, Sendable { + case pager + case image + } + + let minimumScale: Double + let doubleTapScale: Double + let maximumScale: Double + + init( + minimumScale: Double = 1, + doubleTapScale: Double = 3, + maximumScale: Double = 8 + ) { + self.minimumScale = minimumScale + self.doubleTapScale = doubleTapScale + self.maximumScale = maximumScale + } + + func isAtMinimum(_ scale: Double) -> Bool { + abs(scale - minimumScale) <= 0.01 + } + + func horizontalSwipeOwner(at scale: Double) -> HorizontalSwipeOwner { + isAtMinimum(scale) ? .pager : .image + } + + func scaleAfterDoubleTap(currentScale: Double) -> Double { + isAtMinimum(currentScale) ? doubleTapScale : minimumScale + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift new file mode 100644 index 000000000000..387363ae6aa7 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/ChatArtifactZoomableImageView.swift @@ -0,0 +1,117 @@ +#if os(iOS) +import SwiftUI +import UIKit + +/// An aspect-fit image surface with native pinch, pan, and double-tap zoom. +struct ChatArtifactZoomableImageView: UIViewRepresentable { + let image: UIImage + let onMinimumZoomChanged: (Bool) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onMinimumZoomChanged: onMinimumZoomChanged) + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator + scrollView.minimumZoomScale = CGFloat(context.coordinator.policy.minimumScale) + scrollView.maximumZoomScale = CGFloat(context.coordinator.policy.maximumScale) + scrollView.zoomScale = scrollView.minimumZoomScale + scrollView.bouncesZoom = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.showsVerticalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + + let imageView = context.coordinator.imageView + imageView.image = image + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + ]) + + let doubleTap = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.didDoubleTap(_:)) + ) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + context.coordinator.scrollView = scrollView + context.coordinator.reportMinimumZoomIfNeeded(force: true) + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + context.coordinator.onMinimumZoomChanged = onMinimumZoomChanged + if context.coordinator.imageView.image !== image { + context.coordinator.imageView.image = image + scrollView.setZoomScale(scrollView.minimumZoomScale, animated: false) + } + context.coordinator.reportMinimumZoomIfNeeded(force: false) + } + + final class Coordinator: NSObject, UIScrollViewDelegate { + let imageView = UIImageView() + let policy = ChatArtifactZoomPolicy() + weak var scrollView: UIScrollView? + var onMinimumZoomChanged: (Bool) -> Void + private var lastReportedMinimumState: Bool? + + init(onMinimumZoomChanged: @escaping (Bool) -> Void) { + self.onMinimumZoomChanged = onMinimumZoomChanged + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + reportMinimumZoomIfNeeded(force: false) + } + + @objc + func didDoubleTap(_ recognizer: UITapGestureRecognizer) { + guard let scrollView else { return } + let targetScale = CGFloat(policy.scaleAfterDoubleTap( + currentScale: Double(scrollView.zoomScale) + )) + if policy.isAtMinimum(Double(scrollView.zoomScale)) { + let location = recognizer.location(in: imageView) + let width = scrollView.bounds.width / targetScale + let height = scrollView.bounds.height / targetScale + scrollView.zoom( + to: CGRect( + x: location.x - width / 2, + y: location.y - height / 2, + width: width, + height: height + ), + animated: true + ) + } else { + scrollView.setZoomScale(targetScale, animated: true) + } + } + + func reportMinimumZoomIfNeeded(force: Bool) { + guard let scrollView else { return } + let swipeOwner = policy.horizontalSwipeOwner(at: Double(scrollView.zoomScale)) + // The page controller's ancestor pan recognizer must own fitted + // images. A nested UIScrollView pan still wins gesture arbitration + // even when it has no scrollable content, swallowing the page swipe. + scrollView.panGestureRecognizer.isEnabled = swipeOwner == .image + let isAtMinimum = swipeOwner == .pager + guard force || lastReportedMinimumState != isAtMinimum else { return } + lastReportedMinimumState = isAtMinimum + onMinimumZoomChanged(isAtMinimum) + } + } +} +#endif diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssembler.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssembler.swift new file mode 100644 index 000000000000..88e7cfbe47a4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssembler.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Incrementally decodes UTF-8 while retaining only an incomplete trailing scalar. +struct UTF8ChunkAssembler: Sendable { + private var pendingBytes = Data() + + /// Appends raw bytes and returns the complete Unicode scalars now available. + /// + /// - Parameters: + /// - data: The next contiguous bytes in the stream. + /// - eof: Whether these are the final bytes in the stream. + /// - Returns: Decoded text excluding any buffered incomplete trailing scalar. + /// - Throws: ``UTF8ChunkAssemblerError/invalidEncoding`` for malformed UTF-8 + /// or an incomplete scalar at EOF. + mutating func append(_ data: Data, eof: Bool) throws -> String { + var bytes = [UInt8]() + bytes.reserveCapacity(pendingBytes.count + data.count) + bytes.append(contentsOf: pendingBytes) + bytes.append(contentsOf: data) + pendingBytes.removeAll(keepingCapacity: true) + + var index = 0 + var validEnd = bytes.count + while index < bytes.count { + let lead = bytes[index] + if lead <= 0x7F { + index += 1 + continue + } + + let scalarLength = try Self.scalarLength(for: lead) + let availableLength = bytes.count - index + if availableLength < scalarLength { + try Self.validateAvailableScalarPrefix( + bytes, + start: index, + availableLength: availableLength + ) + guard !eof else { + throw UTF8ChunkAssemblerError.invalidEncoding + } + validEnd = index + pendingBytes.append(contentsOf: bytes[index...]) + break + } + + try Self.validateCompleteScalar(bytes, start: index, length: scalarLength) + index += scalarLength + } + + guard validEnd > 0 else { return "" } + return String(decoding: bytes[.. Int { + switch lead { + case 0xC2...0xDF: + return 2 + case 0xE0...0xEF: + return 3 + case 0xF0...0xF4: + return 4 + default: + throw UTF8ChunkAssemblerError.invalidEncoding + } + } + + private static func validateAvailableScalarPrefix( + _ bytes: [UInt8], + start: Int, + availableLength: Int + ) throws { + guard availableLength > 1 else { return } + try validateSecondByte(bytes[start + 1], lead: bytes[start]) + if availableLength > 2 { + for offset in 2.. 2 { + for offset in 2.. Bool { + (0x80...0xBF).contains(byte) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssemblerError.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssemblerError.swift new file mode 100644 index 000000000000..82ceb80b89d1 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkAssemblerError.swift @@ -0,0 +1,4 @@ +/// A byte stream cannot be assembled into valid UTF-8 text. +enum UTF8ChunkAssemblerError: Error, Equatable { + case invalidEncoding +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift new file mode 100644 index 000000000000..5af299a61894 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/UTF8ChunkDecoder.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Owns one UTF-8 assembler while an artifact stream crosses actor boundaries. +actor UTF8ChunkDecoder { + private var assembler = UTF8ChunkAssembler() + private let batcher = ChatArtifactTextStreamBatcher() + + /// Decodes and batches the next raw chunk without moving either operation onto the main actor. + func decodeBatches(_ data: Data, eof: Bool) throws -> [String] { + let decoded = try assembler.append(data, eof: eof) + return batcher.batches(for: decoded) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings index 2e2224edeb83..8b12f9cf1e33 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Resources/Localizable.xcstrings @@ -1,863 +1,1039 @@ { "sourceLanguage": "en", "strings": { - "chat.attachment.image": { + "chat.artifact.accessibility.large_text_truncated": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Image" + "value": "Only the beginning of this large document is exposed to assistive technologies." } }, "ja": { "stringUnit": { "state": "translated", - "value": "画像" + "value": "この大きな書類では、先頭部分のみが支援技術に公開されます。" } - }, - "de": { + } + } + }, + "chat.artifact.action_failed.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Bild" + "value": "Check the connection to your Mac and try again." } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Imagen" + "value": "Macへの接続を確認して、もう一度お試しください。" } - }, - "fr": { + } + } + }, + "chat.artifact.action_failed.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Image" + "value": "Couldn't complete action" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Immagine" + "value": "操作を完了できませんでした" } - }, - "ko": { + } + } + }, + "chat.artifact.actions.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "이미지" + "value": "Referenced Items" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Afbeelding" + "value": "参照項目" } - }, - "pl": { + } + } + }, + "chat.artifact.browse_folder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Obraz" + "value": "Browse folder" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Imagem" + "value": "フォルダを表示" } - }, - "ru": { + } + } + }, + "chat.artifact.copy_contents": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Изображение" + "value": "Copy contents" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Görsel" + "value": "内容をコピー" } - }, - "zh-Hans": { + } + } + }, + "chat.artifact.copy_path": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "图片" + "value": "Copy path" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "圖片" + "value": "パスをコピー" } } } }, - "chat.bubble.copy": { + "chat.artifact.done": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy" + "value": "Done" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コピー" + "value": "完了" } - }, - "de": { + } + } + }, + "chat.artifact.file_actions": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Kopieren" + "value": "File actions" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Copiar" + "value": "ファイル操作" } - }, - "fr": { + } + } + }, + "chat.artifact.file_missing.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Copier" + "value": "The file is no longer available on your Mac." } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Copia" + "value": "そのファイルはMac上で利用できなくなりました。" } - }, - "ko": { + } + } + }, + "chat.artifact.file_missing.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "복사" + "value": "File not found" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Kopiëren" + "value": "ファイルが見つかりません" } - }, - "pl": { + } + } + }, + "chat.artifact.folder.empty": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Kopiuj" + "value": "No items" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Copiar" + "value": "項目がありません" } - }, - "ru": { + } + } + }, + "chat.artifact.folder.load_failed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Копировать" + "value": "Couldn't load this folder" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Kopyala" + "value": "このフォルダを読み込めませんでした" } - }, - "zh-Hans": { + } + } + }, + "chat.artifact.folder.showing_first_500": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "复制" + "value": "Showing first 500 items" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "複製" + "value": "最初の 500 項目を表示しています" } } } }, - "chat.chip.ctrl_c": { + "chat.artifact.forbidden.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "This file was not referenced by the conversation." } }, "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "このファイルは会話で参照されていません。" } - }, - "de": { + } + } + }, + "chat.artifact.forbidden.terminal_message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "This file isn't visible in the current terminal view." } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "このファイルは現在のターミナル画面に表示されていません。" } - }, - "fr": { + } + } + }, + "chat.artifact.forbidden.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "Preview unavailable" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "プレビューできません" } - }, - "ko": { + } + } + }, + "chat.artifact.highlighting.off": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "Highlighting off" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "ハイライトはオフ" } - }, - "pl": { + } + } + }, + "chat.artifact.highlighting.off.explanation": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "This file is %@. Syntax highlighting is off above %@ to keep scrolling smooth." } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "このファイルは%@です。スクロールを滑らかに保つため、%@を超えるファイルでは構文ハイライトがオフになります。" } - }, - "ru": { + } + } + }, + "chat.artifact.jump.bottom": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "Bottom" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "下端" } - }, - "zh-Hans": { + } + } + }, + "chat.artifact.jump.end": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "End" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ctrl-C" + "value": "末尾" } } } }, - "chat.chip.esc": { + "chat.artifact.jump.latest": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Latest" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "最新" } - }, - "de": { + } + } + }, + "chat.artifact.jump.top": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Top" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "先頭" } - }, - "fr": { + } + } + }, + "chat.artifact.line.close": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Échap" + "value": "Close go to line" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "行へ移動を閉じる" } - }, - "ko": { + } + } + }, + "chat.artifact.line.go": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Go" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "移動" } - }, - "pl": { + } + } + }, + "chat.artifact.line.goto": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Go to line" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "行へ移動" } - }, - "ru": { + } + } + }, + "chat.artifact.line.numbers": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Line numbers" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "行番号" } - }, - "zh-Hans": { + } + } + }, + "chat.artifact.line.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "Line number" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "Esc" + "value": "行番号" } } } }, - "chat.chip.stop": { + "chat.artifact.loading": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Stop" + "value": "Loading preview" } }, "ja": { "stringUnit": { "state": "translated", - "value": "停止" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Stopp" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Detener" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Arrêter" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Stop" + "value": "プレビューを読み込み中" } - }, - "ko": { + } + } + }, + "chat.artifact.mac_unreachable.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "중지" + "value": "Check the connection to your Mac and try again." } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Stoppen" + "value": "Macとの接続を確認して、もう一度お試しください。" } - }, - "pl": { + } + } + }, + "chat.artifact.mac_unreachable.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Zatrzymaj" + "value": "Mac unreachable" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Parar" + "value": "Macに接続できません" } - }, - "ru": { + } + } + }, + "chat.artifact.markdown.raw": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Стоп" + "value": "Raw" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Durdur" + "value": "ソース" } - }, - "zh-Hans": { + } + } + }, + "chat.artifact.markdown.rendered": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "停止" + "value": "Rendered" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "停止" + "value": "プレビュー" } } } }, - "chat.chip.terminal": { + "chat.artifact.markdown.view": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Markdown view" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナル" + "value": "Markdown表示" } - }, - "de": { + } + } + }, + "chat.artifact.ok": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "OK" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "OK" } - }, - "fr": { + } + } + }, + "chat.artifact.open_item": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Open item" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Terminale" + "value": "項目を開く" } - }, - "ko": { + } + } + }, + "chat.artifact.preview_unavailable.message": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "터미널" + "value": "This file can't be previewed." } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "このファイルはプレビューできません。" } - }, - "pl": { + } + } + }, + "chat.artifact.preview_unavailable.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "Preview unavailable" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "プレビューできません" } - }, - "ru": { + } + } + }, + "chat.artifact.retry": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Терминал" + "value": "Retry" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Terminal" + "value": "再試行" } - }, - "zh-Hans": { + } + } + }, + "chat.artifact.save_to_files": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "终端" + "value": "Save to Files" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "終端機" + "value": "「ファイル」に保存" } } } }, - "chat.code.language.accessibility": { + "chat.artifact.search.close": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ code" + "value": "Close search" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@ コード" + "value": "検索を閉じる" } - }, - "de": { + } + } + }, + "chat.artifact.search.next": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "%@-Code" + "value": "Next match" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Código %@" + "value": "次の一致" } - }, - "fr": { + } + } + }, + "chat.artifact.search.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Code %@" + "value": "Find in file" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Codice %@" + "value": "ファイル内を検索" } - }, - "ko": { + } + } + }, + "chat.artifact.search.previous": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "%@ 코드" + "value": "Previous match" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "%@-code" + "value": "前の一致" } - }, - "pl": { + } + } + }, + "chat.artifact.search.still_loading": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Kod %@" + "value": "Still loading" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "código %@" + "value": "読み込み中" } - }, - "ru": { + } + } + }, + "chat.artifact.search.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Код %@" + "value": "Search" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "%@ kodu" + "value": "検索" } - }, - "zh-Hans": { + } + } + }, + "chat.artifact.share": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "%@ 代码" + "value": "Share" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "%@ 程式碼" + "value": "共有" } } } }, - "chat.detail.code.section": { + "chat.artifact.text_options": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Code" + "value": "Text options" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コード" + "value": "テキストオプション" } } } }, - "chat.detail.code.title": { + "chat.artifact.too_large.limit_message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Code Block" + "value": "This preview is limited to %@." } }, "ja": { "stringUnit": { "state": "translated", - "value": "コードブロック" + "value": "このプレビューの上限は%@です。" } } } }, - "chat.detail.command": { + "chat.artifact.too_large.message": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Command" + "value": "This file is %@; previews are limited to %@." } }, "ja": { "stringUnit": { "state": "translated", - "value": "コマンド" + "value": "このファイルのサイズは%@です。プレビューの上限は%@です。" } } } }, - "chat.detail.copy_all": { + "chat.artifact.too_large.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Copy All" + "value": "File too large to preview" } }, "ja": { "stringUnit": { "state": "translated", - "value": "すべてコピー" + "value": "ファイルが大きすぎてプレビューできません" } } } }, - "chat.detail.diff": { + "chat.artifact.view_file": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Diff" + "value": "View file" } }, "ja": { "stringUnit": { "state": "translated", - "value": "差分" + "value": "ファイルを表示" } } } }, - "chat.detail.done": { + "chat.artifact.viewer.actions": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Done" + "value": "Viewer actions" } }, "ja": { "stringUnit": { "state": "translated", - "value": "完了" + "value": "ビューア操作" } } } }, - "chat.detail.empty": { + "chat.artifact.wrap": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No details available" + "value": "Word wrap" } }, "ja": { "stringUnit": { "state": "translated", - "value": "詳細はありません" + "value": "行を折り返す" } } } }, - "chat.detail.file.title": { + "chat.attachment.image": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File Change" + "value": "Image" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ファイル変更" + "value": "画像" } } } }, - "chat.detail.file.subtitle": { + "chat.bubble.copy": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ - %@" + "value": "Copy" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@(%@)" + "value": "コピー" } } } }, - "chat.detail.operation.delete": { + "chat.chip.ctrl_c": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Delete" + "value": "Ctrl-C" } }, "ja": { "stringUnit": { "state": "translated", - "value": "削除" + "value": "Ctrl-C" } } } }, - "chat.detail.operation.edit": { + "chat.chip.esc": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Edit" + "value": "Esc" } }, "ja": { "stringUnit": { "state": "translated", - "value": "編集" + "value": "Esc" } } } }, - "chat.detail.operation.write": { + "chat.chip.stop": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Write" + "value": "Stop" } }, "ja": { "stringUnit": { "state": "translated", - "value": "書き込み" + "value": "停止" } } } }, - "chat.detail.output": { + "chat.chip.terminal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Output" + "value": "Terminal" } }, "ja": { "stringUnit": { "state": "translated", - "value": "出力" + "value": "ターミナル" } } } }, - "chat.detail.show.hint": { + "chat.code.language.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Opens a sheet with the full block content" + "value": "%@ code" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ブロック全体を表示するシートを開きます" + "value": "%@ コード" } } } @@ -879,6018 +1055,1469 @@ } } }, - "chat.detail.status.failed": { + "chat.composer.attach.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Failed" + "value": "Add attachment" } }, "ja": { "stringUnit": { "state": "translated", - "value": "失敗" + "value": "添付を追加" } } } }, - "chat.detail.status.running": { + "chat.composer.attachment.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Running" + "value": "Attachment %lld" } }, "ja": { "stringUnit": { "state": "translated", - "value": "実行中" + "value": "添付ファイル %lld" } } } }, - "chat.detail.status.succeeded": { + "chat.composer.open_terminal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Succeeded" + "value": "Open terminal" } }, "ja": { "stringUnit": { "state": "translated", - "value": "成功" + "value": "ターミナルを開く" } } } }, - "chat.detail.terminal.title": { + "chat.composer.placeholder": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Terminal Output" + "value": "Message %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナル出力" + "value": "%@にメッセージ" } } } }, - "chat.detail.thought.section": { + "chat.composer.placeholder.terminal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reasoning" + "value": "❯ command" } }, "ja": { "stringUnit": { "state": "translated", - "value": "推論" + "value": "❯ コマンド" } } } }, - "chat.detail.tool.input": { + "chat.composer.remove_attachment.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Input" + "value": "Remove attachment %lld" } }, "ja": { "stringUnit": { "state": "translated", - "value": "入力" + "value": "添付ファイル %lld を削除" } } } }, - "chat.detail.tool.output": { + "chat.composer.send.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Output" + "value": "Send" } }, "ja": { "stringUnit": { "state": "translated", - "value": "出力" + "value": "送信" } } } }, - "chat.detail.tool.summary": { + "chat.composer.session_ended": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Summary" + "value": "Session ended" } }, "ja": { "stringUnit": { "state": "translated", - "value": "概要" + "value": "セッションが終了しました" } } } }, - "chat.detail.tool.subtitle": { + "chat.composer.stop.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "%@ - %@" + "value": "Stop" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@(%@)" + "value": "停止" } } } }, - "chat.detail.tool.title": { + "chat.detail.code.section": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Tool Details" + "value": "Code" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ツール詳細" + "value": "コード" } } } }, - "chat.composer.attach.accessibility": { + "chat.detail.code.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Add attachment" + "value": "Code Block" } }, "ja": { "stringUnit": { "state": "translated", - "value": "添付を追加" + "value": "コードブロック" } - }, - "de": { + } + } + }, + "chat.detail.command": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Anhang hinzufügen" + "value": "Command" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Añadir adjunto" + "value": "コマンド" } - }, - "fr": { + } + } + }, + "chat.detail.copy_all": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ajouter une pièce jointe" + "value": "Copy All" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Aggiungi allegato" + "value": "すべてコピー" } - }, - "ko": { + } + } + }, + "chat.detail.diff": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "첨부 파일 추가" + "value": "Diff" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Bijlage toevoegen" + "value": "差分" } - }, - "pl": { + } + } + }, + "chat.detail.done": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Dodaj załącznik" + "value": "Done" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Adicionar anexo" + "value": "完了" } - }, - "ru": { + } + } + }, + "chat.detail.empty": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Добавить вложение" + "value": "No details available" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ek ekle" + "value": "詳細はありません" } - }, - "zh-Hans": { + } + } + }, + "chat.detail.file.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "添加附件" + "value": "%@ - %@" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "新增附件" + "value": "%@(%@)" } } } }, - "chat.composer.attachment.accessibility": { + "chat.detail.file.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Attachment %lld" + "value": "File Change" } }, "ja": { "stringUnit": { "state": "translated", - "value": "添付ファイル %lld" + "value": "ファイル変更" } - }, - "de": { + } + } + }, + "chat.detail.operation.delete": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Anhang %lld" + "value": "Delete" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Adjunto %lld" + "value": "削除" } - }, - "fr": { + } + } + }, + "chat.detail.operation.edit": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Pièce jointe %lld" + "value": "Edit" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Allegato %lld" + "value": "編集" } - }, - "ko": { + } + } + }, + "chat.detail.operation.write": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "첨부 파일 %lld" + "value": "Write" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Bijlage %lld" + "value": "書き込み" } - }, - "pl": { + } + } + }, + "chat.detail.output": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Załącznik %lld" + "value": "Output" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Anexo %lld" + "value": "出力" } - }, - "ru": { + } + } + }, + "chat.detail.show.hint": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Вложение %lld" + "value": "Opens a sheet with the full block content" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "%lld. ek" + "value": "ブロック全体を表示するシートを開きます" } - }, - "zh-Hans": { + } + } + }, + "chat.detail.status.failed": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "附件 %lld" + "value": "Failed" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "附件 %lld" + "value": "失敗" } } } }, - "chat.composer.open_terminal": { + "chat.detail.status.running": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Open terminal" + "value": "Running" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ターミナルを開く" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Terminal öffnen" + "value": "実行中" } - }, - "es": { + } + } + }, + "chat.detail.status.succeeded": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Abrir terminal" + "value": "Succeeded" } }, - "fr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ouvrir le terminal" + "value": "成功" } - }, - "it": { + } + } + }, + "chat.detail.terminal.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Apri terminale" + "value": "Terminal Output" } }, - "ko": { + "ja": { "stringUnit": { "state": "translated", - "value": "터미널 열기" + "value": "ターミナル出力" } - }, - "nl": { + } + } + }, + "chat.detail.thought.section": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Terminal openen" + "value": "Reasoning" } }, - "pl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Otwórz terminal" + "value": "推論" } - }, - "pt-BR": { + } + } + }, + "chat.detail.tool.input": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Abrir terminal" + "value": "Input" } }, - "ru": { + "ja": { "stringUnit": { "state": "translated", - "value": "Открыть терминал" + "value": "入力" } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Terminali aç" - } - }, - "zh-Hans": { + } + } + }, + "chat.detail.tool.output": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "打开终端" + "value": "Output" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "開啟終端機" + "value": "出力" } } } }, - "chat.composer.placeholder": { + "chat.detail.tool.subtitle": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Message %@" + "value": "%@ - %@" } }, "ja": { "stringUnit": { "state": "translated", - "value": "%@にメッセージ" + "value": "%@(%@)" } - }, - "de": { + } + } + }, + "chat.detail.tool.summary": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Nachricht %@" + "value": "Summary" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Mensaje %@" + "value": "概要" } - }, - "fr": { + } + } + }, + "chat.detail.tool.title": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Message %@" + "value": "Tool Details" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Messaggio %@" + "value": "ツール詳細" } - }, - "ko": { + } + } + }, + "chat.diff.added.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "%@에게 메시지" + "value": "Added: %@" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Bericht %@" + "value": "追加: %@" } - }, - "pl": { + } + } + }, + "chat.diff.hunk.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Wiadomość %@" + "value": "Section: %@" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Mensagem %@" + "value": "セクション: %@" } - }, - "ru": { + } + } + }, + "chat.diff.removed.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Сообщение %@" + "value": "Removed: %@" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "%@ için mesaj" + "value": "削除: %@" } - }, - "zh-Hans": { + } + } + }, + "chat.file_edit.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "发消息给 %@" + "value": "File edit" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "訊息 %@" + "value": "ファイル編集" } } } }, - "chat.composer.placeholder.terminal": { + "chat.file_edit.additions.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "❯ command" + "value": "additions" } }, "ja": { "stringUnit": { "state": "translated", - "value": "❯ コマンド" + "value": "追加" } - }, - "de": { + } + } + }, + "chat.file_edit.deletions.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "❯ Befehl" + "value": "deletions" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "❯ comando" + "value": "削除" } - }, - "fr": { + } + } + }, + "chat.header.reconnecting": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "❯ commande" + "value": "reconnecting…" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "❯ comando" + "value": "再接続中…" } - }, - "ko": { + } + } + }, + "chat.header.state.ended": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "❯ 명령어" + "value": "ended" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "❯ opdracht" + "value": "終了" } - }, - "pl": { + } + } + }, + "chat.header.state.idle": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "❯ polecenie" + "value": "idle" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "❯ comando" + "value": "待機中" } - }, - "ru": { + } + } + }, + "chat.header.state.needs_input": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "❯ команда" + "value": "needs input" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "❯ komut" + "value": "入力待ち" } - }, - "zh-Hans": { + } + } + }, + "chat.header.state.working": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "❯ 命令" + "value": "working" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "❯ 指令" + "value": "作業中" } } } }, - "chat.composer.remove_attachment.accessibility": { + "chat.history.truncated": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Remove attachment %lld" + "value": "Earlier history is on your Mac" } }, "ja": { "stringUnit": { "state": "translated", - "value": "添付ファイル %lld を削除" + "value": "これより前の履歴はMac側にあります" } - }, - "de": { + } + } + }, + "chat.open_terminal.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Anhang %lld entfernen" + "value": "Open terminal" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Eliminar adjunto %lld" + "value": "ターミナルを開く" } - }, - "fr": { + } + } + }, + "chat.pending.attachments.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Supprimer la pièce jointe %lld" + "value": "%lld attachments" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Rimuovi allegato %lld" + "value": "%lld件の添付ファイル" } - }, - "ko": { + } + } + }, + "chat.pending.cancel": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "첨부 파일 %lld 제거" + "value": "Cancel" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Bijlage %lld verwijderen" + "value": "キャンセル" } - }, - "pl": { + } + } + }, + "chat.pending.delivered.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Usuń załącznik %lld" + "value": "Delivered" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Remover anexo %lld" + "value": "送信済み" } - }, - "ru": { + } + } + }, + "chat.pending.discard": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Удалить вложение %lld" + "value": "Discard" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "%lld. eki kaldır" + "value": "破棄" } - }, - "zh-Hans": { + } + } + }, + "chat.pending.failed.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "移除附件 %lld" + "value": "Failed to send" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "移除附件 %lld" + "value": "送信に失敗しました" } } } }, - "chat.composer.send.accessibility": { + "chat.pending.queued.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Send" + "value": "Queued until the agent is free" } }, "ja": { "stringUnit": { "state": "translated", - "value": "送信" + "value": "エージェントが空くまで送信待ち" } - }, - "de": { + } + } + }, + "chat.pending.retry": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Senden" + "value": "Retry" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Enviar" + "value": "再試行" } - }, - "fr": { + } + } + }, + "chat.pending.sending.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Envoyer" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Invia" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "전송" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Versturen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Wyślij" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Enviar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отправить" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Gönder" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "发送" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "傳送" - } - } - } - }, - "chat.composer.session_ended": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Session ended" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "セッションが終了しました" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Sitzung beendet" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sesión finalizada" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Session terminée" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Sessione terminata" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "세션 종료됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Sessie beëindigd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Sesja zakończona" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Sessão encerrada" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Сессия завершена" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Oturum sona erdi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "会话已结束" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "工作階段已結束" - } - } - } - }, - "chat.composer.stop.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "停止" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Stopp" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Detener" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Arrêter" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Stop" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "중지" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Stoppen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Zatrzymaj" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Parar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Стоп" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Durdur" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "停止" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "停止" - } - } - } - }, - "chat.diff.added.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Added: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加: %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Hinzugefügt: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Añadido: %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ajouté : %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Aggiunto: %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "추가됨: %@" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Toegevoegd: %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Dodano: %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Adicionado: %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Добавлено: %@" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Eklendi: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "新增:%@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已新增:%@" - } - } - } - }, - "chat.diff.hunk.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Section: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "セクション: %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abschnitt: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sección: %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Section : %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Sezione: %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "섹션: %@" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Sectie: %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Sekcja: %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Seção: %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Секция: %@" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bölüm: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "片段:%@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "區段:%@" - } - } - } - }, - "chat.diff.removed.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Removed: %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "削除: %@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Entfernt: %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Eliminado: %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Supprimé : %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Rimosso: %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "제거됨: %@" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Verwijderd: %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Usunięto: %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Removido: %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Удалено: %@" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Kaldırıldı: %@" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "删除:%@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已移除:%@" - } - } - } - }, - "chat.file_edit.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "File edit" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ファイル編集" - } - } - } - }, - "chat.file_edit.additions.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "additions" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "追加" - } - } - } - }, - "chat.file_edit.deletions.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "deletions" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "削除" - } - } - } - }, - "chat.header.reconnecting": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "reconnecting…" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再接続中…" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbindung wird wiederhergestellt…" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "reconectando…" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "reconnexion…" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "riconnessione…" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "재연결 중…" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "opnieuw verbinden…" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "ponowne łączenie…" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "reconectando…" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "переподключение…" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "yeniden bağlanıyor…" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重新连接中…" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重新連線中…" - } - } - } - }, - "chat.header.state.ended": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "ended" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "終了" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "beendet" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "finalizado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "terminée" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "terminata" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "종료됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "beëindigd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "zakończono" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "encerrado" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "завершено" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "sona erdi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已结束" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已結束" - } - } - } - }, - "chat.header.state.idle": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "idle" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "待機中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "inaktiv" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "inactivo" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "inactive" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "inattiva" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "대기 중" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "inactief" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "bezczynny" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "ocioso" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "ожидание" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "boşta" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "空闲" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "閒置" - } - } - } - }, - "chat.header.state.needs_input": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "needs input" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "入力待ち" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Eingabe erforderlich" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "necesita entrada" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "attend une saisie" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "richiede input" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "입력 필요" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "invoer vereist" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "wymaga danych" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "aguardando entrada" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "ожидает ввода" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "giriş bekliyor" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "等待输入" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "需要輸入" - } - } - } - }, - "chat.header.state.working": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "working" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "作業中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "arbeitet" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "trabajando" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "en cours" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "in elaborazione" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "작업 중" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "bezig" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "pracuje" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "trabalhando" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "работает" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "çalışıyor" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "运行中" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "執行中" - } - } - } - }, - "chat.history.truncated": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Earlier history is on your Mac" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "これより前の履歴はMac側にあります" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Ältere Verläufe befinden sich auf dem Mac" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "El historial anterior está en tu Mac" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "L'historique antérieur est sur votre Mac" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "La cronologia precedente è sul tuo Mac" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이전 기록은 Mac에 있습니다" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Eerdere geschiedenis staat op je Mac" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Wcześniejsza historia jest na Macu" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "O histórico anterior está no seu Mac" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Более ранняя история — на вашем Mac" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Daha önceki geçmiş Mac'inizde" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "更早的历史记录在您的 Mac 上" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "更早的歷史記錄在您的 Mac 上" - } - } - } - }, - "chat.open_terminal.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ターミナルを開く" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Terminal öffnen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir terminal" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ouvrir le terminal" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Apri terminale" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "터미널 열기" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Terminal openen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Otwórz terminal" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Abrir terminal" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Открыть терминал" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Terminali aç" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "打开终端" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "開啟終端機" - } - } - } - }, - "chat.pending.attachments.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld attachments" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%lld件の添付ファイル" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Anhänge" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%lld adjuntos" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%lld pièces jointes" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%lld allegati" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "첨부 파일 %lld개" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "%lld bijlagen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%lld załączników" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%lld anexos" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%lld вложений" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%lld ek" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%lld 个附件" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%lld 個附件" - } - } - } - }, - "chat.pending.cancel": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "キャンセル" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abbrechen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Cancelar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Annuler" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Annulla" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "취소" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Annuleren" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Anuluj" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Cancelar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отмена" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "İptal" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "取消" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "取消" - } - } - } - }, - "chat.pending.delivered.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Delivered" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "送信済み" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Zugestellt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Entregado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Livré" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Consegnato" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "전달됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Afgeleverd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Dostarczono" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Entregue" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Доставлено" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Teslim edildi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已送达" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已送達" - } - } - } - }, - "chat.pending.discard": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Discard" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "破棄" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Verwerfen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Descartar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ignorer" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Scarta" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "삭제" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Verwijderen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Odrzuć" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Descartar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отбросить" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "At" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "丢弃" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "捨棄" - } - } - } - }, - "chat.pending.failed.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed to send" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "送信に失敗しました" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Senden fehlgeschlagen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Error al enviar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Échec de l'envoi" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Invio fallito" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "전송 실패" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Versturen mislukt" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nie udało się wysłać" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Falha ao enviar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Не удалось отправить" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Gönderme başarısız" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "发送失败" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "傳送失敗" - } - } - } - }, - "chat.pending.queued.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Queued until the agent is free" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントが空くまで送信待ち" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Wartet bis der Agent frei ist" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "En cola hasta que el agente esté libre" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "En attente que l'agent soit disponible" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "In coda finché l'agente è libero" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트가 준비될 때까지 대기 중" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "In de wachtrij totdat de agent beschikbaar is" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "W kolejce do czasu gdy agent będzie wolny" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Na fila até o agente estar livre" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "В очереди до освобождения агента" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Ajan müsait olana kadar sırada" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "等待 agent 空闲后发送" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已排隊,等待 agent 空閒" - } - } - } - }, - "chat.pending.retry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Retry" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再試行" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Wiederholen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reintentar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réessayer" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Riprova" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다시 시도" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Opnieuw proberen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Ponów" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Tentar novamente" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Повторить" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Tekrar Dene" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重试" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重試" - } - } - } - }, - "chat.pending.sending.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Sending" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "送信中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Wird gesendet" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Enviando" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Envoi en cours" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Invio in corso" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "전송 중" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Versturen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Wysyłanie" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Enviando" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отправка" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Gönderiliyor" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "发送中" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "傳送中" - } - } - } - }, - "chat.permission.approve": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Approve" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "許可" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Genehmigen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aprobar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Approuver" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Approva" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "승인" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Goedkeuren" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Zatwierdź" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Aprovar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Разрешить" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Onayla" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "批准" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "核准" - } - } - } - }, - "chat.permission.approved": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Approved" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "許可済み" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Genehmigt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Aprobado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Approuvé" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Approvato" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "승인됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Goedgekeurd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Zatwierdzone" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Aprovado" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Разрешено" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Onaylandı" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已批准" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已核准" - } - } - } - }, - "chat.permission.denied": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Denied" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "拒否済み" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abgelehnt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Denegado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Refusé" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Negato" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "거부됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Geweigerd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Odrzucono" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Negado" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отклонено" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Reddedildi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已拒绝" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已拒絕" - } - } - } - }, - "chat.permission.deny": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Deny" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "拒否" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Ablehnen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Denegar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Refuser" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Nega" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "거부" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Weigeren" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Odrzuć" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Negar" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Отклонить" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Reddet" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "拒绝" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "拒絕" - } - } - } - }, - "chat.permission.expired": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Expired" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "期限切れ" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Abgelaufen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Expirado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Expiré" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Scaduto" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "만료됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Verlopen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Wygasło" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Expirado" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Истекло" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Süresi Doldu" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已过期" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已過期" - } - } - } - }, - "chat.row.collapse.hint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Double tap to collapse" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダブルタップで折りたたむ" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Doppeltippen zum Einklappen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Toca dos veces para contraer" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Double-appuyez pour réduire" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Doppio tocco per comprimere" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "두 번 탭하여 접기" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Dubbel tikken om samen te vouwen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Dotknij dwukrotnie, aby zwinąć" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Toque duplo para recolher" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Двойное касание для сворачивания" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Daraltmak için çift dokun" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "双击收起" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "連按兩下以收合" - } - } - } - }, - "chat.row.collapsed.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Collapsed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "折りたたみ済み" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Eingeklappt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contraído" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réduit" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Compresso" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "접힘" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Samengevouwen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Zwinięte" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Recolhido" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Свёрнуто" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Daraltıldı" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已收起" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已收合" - } - } - } - }, - "chat.row.expand.hint": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Double tap to expand" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ダブルタップで展開" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Doppeltippen zum Ausklappen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Toca dos veces para expandir" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Double-appuyez pour développer" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Doppio tocco per espandere" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "두 번 탭하여 펼치기" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Dubbel tikken om uit te vouwen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Dotknij dwukrotnie, aby rozwinąć" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Toque duplo para expandir" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Двойное касание для разворачивания" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Genişletmek için çift dokun" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "双击展开" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "連按兩下以展開" - } - } - } - }, - "chat.row.expanded.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Expanded" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "展開済み" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Ausgeklappt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Expandido" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Développé" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Espanso" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "펼쳐짐" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Uitgevouwen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Rozwinięte" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Expandido" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Развёрнуто" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Genişletildi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已展开" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已展開" - } - } - } - }, - "chat.scroll_to_bottom.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Scroll to latest message" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "最新メッセージへスクロール" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Zur neuesten Nachricht scrollen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Desplazarse al último mensaje" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Faire défiler jusqu'au dernier message" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Scorri all'ultimo messaggio" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "최신 메시지로 이동" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Naar laatste bericht scrollen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Przewiń do najnowszej wiadomości" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Rolar para a última mensagem" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Перейти к последнему сообщению" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "En son mesaja git" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "滚动到最新消息" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "捲動至最新訊息" - } - } - } - }, - "chat.status.context_compacted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Context compacted" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コンテキストを圧縮しました" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Kontext komprimiert" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Contexto compactado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Contexte compacté" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Contesto compattato" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "컨텍스트 압축됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Context gecomprimeerd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Kontekst skompaktowany" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Contexto compactado" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Контекст сжат" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bağlam sıkıştırıldı" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "上下文已压缩" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已壓縮內容" - } - } - } - }, - "chat.status.interrupted": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Interrupted" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "中断されました" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Unterbrochen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Interrumpido" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Interrompu" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Interrotto" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "중단됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Onderbroken" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Przerwano" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Interrompido" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Прервано" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Kesildi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "已中断" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "已中斷" - } - } - } - }, - "chat.status.session_ended": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Session ended" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "セッション終了" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Sitzung beendet" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sesión finalizada" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Session terminée" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Sessione terminata" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "세션 종료됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Sessie beëindigd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Sesja zakończona" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Sessão encerrada" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Сессия завершена" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Oturum sona erdi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "会话已结束" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "工作階段已結束" - } - } - } - }, - "chat.status.session_started": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Session started" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "セッション開始" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Sitzung gestartet" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Sesión iniciada" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Session démarrée" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Sessione avviata" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "세션 시작됨" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Sessie gestart" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Sesja rozpoczęta" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Sessão iniciada" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Сессия начата" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Oturum başladı" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "会话已开始" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "工作階段已開始" - } - } - } - }, - "chat.terminal.collapse.action": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Show less output" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "出力を折りたたむ" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Weniger Ausgabe anzeigen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Mostrar menos salida" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Afficher moins de sortie" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Mostra meno output" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "출력 줄이기" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Minder uitvoer tonen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Pokaż mniej danych wyjściowych" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Mostrar menos saída" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Показать меньше вывода" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Daha az çıktı göster" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "收起输出" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "顯示較少輸出" - } - } - } - }, - "chat.terminal.command.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Command %@" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コマンド%@" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Befehl %@" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Comando %@" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Commande %@" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Comando %@" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "명령어 %@" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Opdracht %@" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Polecenie %@" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Comando %@" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Команда %@" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ komutu" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "命令 %@" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "指令 %@" - } - } - } - }, - "chat.terminal.expand.action": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Show all output" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "すべての出力を表示" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Gesamte Ausgabe anzeigen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Mostrar toda la salida" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Afficher toute la sortie" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Mostra tutto l'output" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "전체 출력 보기" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Alle uitvoer tonen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Pokaż wszystkie dane wyjściowe" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Mostrar toda a saída" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Показать весь вывод" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Tüm çıktıyı göster" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "展开全部输出" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "顯示全部輸出" - } - } - } - }, - "chat.terminal.failed.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Command %@, failed, exit code %lld" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コマンド%@、失敗、終了コード%lld" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Befehl %@, fehlgeschlagen, Exit-Code %lld" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, fallido, código de salida %lld" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Commande %@, échec, code de sortie %lld" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, fallito, codice di uscita %lld" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "명령어 %@, 실패, 종료 코드 %lld" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Opdracht %@, mislukt, afsluitcode %lld" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Polecenie %@, nieudane, kod wyjścia %lld" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, falhou, código de saída %lld" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Команда %@, ошибка, код выхода %lld" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ komutu, başarısız, çıkış kodu %lld" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "命令 %@,失败,退出码 %lld" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "指令 %@,失敗,結束代碼 %lld" - } - } - } - }, - "chat.terminal.interactive": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Interactive program — open the full terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "インタラクティブプログラム — フルターミナルを開く" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Interaktives Programm – volles Terminal öffnen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Programa interactivo: abre el terminal completo" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Programme interactif — ouvrez le terminal complet" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Programma interattivo — apri il terminale completo" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "대화형 프로그램 — 전체 터미널 열기" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Interactief programma — open de volledige terminal" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Program interaktywny — otwórz pełny terminal" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Programa interativo — abra o terminal completo" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Интерактивная программа — откройте полный терминал" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Etkileşimli program — tam terminali açın" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "交互式程序 — 打开完整终端" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "互動式程式——請開啟完整終端機" - } - } - } - }, - "chat.terminal.interactive.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ is an interactive program. Open the full terminal." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "%@はインタラクティブプログラムです。フルターミナルを開いてください。" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ ist ein interaktives Programm. Volles Terminal öffnen." - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "%@ es un programa interactivo. Abre el terminal completo." - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "%@ est un programme interactif. Ouvrez le terminal complet." - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "%@ è un programma interattivo. Apri il terminale completo." - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "%@은(는) 대화형 프로그램입니다. 전체 터미널을 여세요." - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "%@ is een interactief programma. Open de volledige terminal." - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "%@ to program interaktywny. Otwórz pełny terminal." - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "%@ é um programa interativo. Abra o terminal completo." - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "%@ — интерактивная программа. Откройте полный терминал." - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ etkileşimli bir program. Tam terminali açın." - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "%@ 是交互式程序,请打开完整终端。" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "%@ 是互動式程式。請開啟完整終端機。" - } - } - } - }, - "chat.terminal.more_lines": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld more lines" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "⋯ 残り%lld行" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld weitere Zeilen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld líneas más" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld lignes supplémentaires" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld righe in più" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld줄 더" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld meer regels" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "⋯ jeszcze %lld linii" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "⋯ mais %lld linhas" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "⋯ ещё %lld строк" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "⋯ %lld satır daha" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "⋯ 还有 %lld 行" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "⋯ 還有 %lld 行" - } - } - } - }, - "chat.terminal.open_in_terminal": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Open in terminal" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "ターミナルで開く" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Im Terminal öffnen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Abrir en el terminal" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Ouvrir dans le terminal" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Apri nel terminale" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "터미널에서 열기" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Openen in terminal" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Otwórz w terminalu" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Abrir no terminal" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Открыть в терминале" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Terminalde aç" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "在终端中打开" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "在終端機中開啟" - } - } - } - }, - "chat.terminal.running": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "running" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "実行中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "läuft" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "ejecutando" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "en cours" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "in esecuzione" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "실행 중" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "actief" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "działa" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "em execução" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "выполняется" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "çalışıyor" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "运行中" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "執行中" - } - } - } - }, - "chat.terminal.running.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Command %@, running" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コマンド%@、実行中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Befehl %@, läuft" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, ejecutando" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Commande %@, en cours" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, in esecuzione" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "명령어 %@, 실행 중" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Opdracht %@, actief" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Polecenie %@, działa" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, em execução" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Команда %@, выполняется" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ komutu, çalışıyor" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "命令 %@,运行中" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "指令 %@,執行中" - } - } - } - }, - "chat.terminal.succeeded.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Command %@, succeeded" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "コマンド%@、成功" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Befehl %@, erfolgreich" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, completado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Commande %@, réussie" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, riuscito" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "명령어 %@, 성공" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Opdracht %@, geslaagd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Polecenie %@, zakończone sukcesem" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Comando %@, concluído" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Команда %@, выполнена успешно" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "%@ komutu, başarılı" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "命令 %@,已成功" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "指令 %@,已成功" - } - } - } - }, - "chat.thought.title": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Thought" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "思考" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Gedanke" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Pensamiento" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réflexion" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Pensiero" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "생각" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Gedachte" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Przemyślenie" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Pensamento" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Мысль" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Düşünce" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "思考" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "思考" - } - } - } - }, - "chat.tool.failed.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "失敗" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Fehlgeschlagen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Fallido" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Échec" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Fallito" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "실패" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Mislukt" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nieudane" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Falhou" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Ошибка" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Başarısız" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "失败" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "失敗" - } - } - } - }, - "chat.tool.running.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Running" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "実行中" - } - } - } - }, - "chat.tool.succeeded.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Succeeded" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "成功" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Erfolgreich" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Completado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réussi" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Riuscito" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "성공" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Geslaagd" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Zakończone sukcesem" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Concluído" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Успешно" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Başarılı" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "成功" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "成功" - } - } - } - }, - "chat.tool.truncated": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "⋯ truncated" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "⋯ 省略" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "⋯ gekürzt" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "⋯ truncado" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "⋯ tronqué" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "⋯ troncato" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "⋯ 잘림" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "⋯ afgekapt" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "⋯ obcięto" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "⋯ truncado" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "⋯ усечено" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "⋯ kısaltıldı" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "⋯ 已截断" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "⋯ 已截斷" - } - } - } - }, - "chat.transcript.empty": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "No messages yet" + "value": "Sending" } }, "ja": { "stringUnit": { "state": "translated", - "value": "まだメッセージはありません" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Noch keine Nachrichten" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No hay mensajes aún" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Aucun message pour l'instant" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Nessun messaggio ancora" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "아직 메시지가 없습니다" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Nog geen berichten" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Brak wiadomości" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Nenhuma mensagem ainda" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Сообщений пока нет" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Henüz mesaj yok" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "暂无消息" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "尚無訊息" + "value": "送信中" } } } }, - "chat.transcript.load_failed": { + "chat.permission.approve": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Couldn't load this conversation" + "value": "Approve" } }, "ja": { "stringUnit": { "state": "translated", - "value": "この会話を読み込めませんでした" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Dieses Gespräch konnte nicht geladen werden" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "No se pudo cargar esta conversación" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Impossible de charger cette conversation" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Impossibile caricare questa conversazione" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "이 대화를 불러올 수 없습니다" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Kon dit gesprek niet laden" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Nie można załadować tej rozmowy" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Não foi possível carregar esta conversa" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Не удалось загрузить этот разговор" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Bu konuşma yüklenemedi" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "无法加载此对话" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "無法載入此對話" + "value": "許可" } } } }, - "chat.transcript.retry": { + "chat.permission.approved": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Retry" + "value": "Approved" } }, "ja": { "stringUnit": { "state": "translated", - "value": "再試行" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Wiederholen" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "Reintentar" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "Réessayer" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "Riprova" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "다시 시도" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Opnieuw proberen" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Ponów" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "Tentar novamente" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Повторить" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Tekrar Dene" - } - }, - "zh-Hans": { - "stringUnit": { - "state": "translated", - "value": "重试" - } - }, - "zh-Hant": { - "stringUnit": { - "state": "translated", - "value": "重試" + "value": "許可済み" } } } }, - "chat.typing.accessibility": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Agent is working" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "エージェントが作業中" - } - }, - "de": { - "stringUnit": { - "state": "translated", - "value": "Agent arbeitet" - } - }, - "es": { - "stringUnit": { - "state": "translated", - "value": "El agente está trabajando" - } - }, - "fr": { - "stringUnit": { - "state": "translated", - "value": "L'agent travaille" - } - }, - "it": { - "stringUnit": { - "state": "translated", - "value": "L'agente sta lavorando" - } - }, - "ko": { - "stringUnit": { - "state": "translated", - "value": "에이전트가 작업 중입니다" - } - }, - "nl": { - "stringUnit": { - "state": "translated", - "value": "Agent is bezig" - } - }, - "pl": { - "stringUnit": { - "state": "translated", - "value": "Agent pracuje" - } - }, - "pt-BR": { - "stringUnit": { - "state": "translated", - "value": "O agente está trabalhando" - } - }, - "ru": { - "stringUnit": { - "state": "translated", - "value": "Агент работает" - } - }, - "tr": { - "stringUnit": { - "state": "translated", - "value": "Ajan çalışıyor" - } - }, - "zh-Hans": { + "chat.permission.denied": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Agent 正在工作" + "value": "Denied" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "Agent 正在執行" + "value": "拒否済み" } } } }, - "chat.unread_separator": { + "chat.permission.deny": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unread messages" + "value": "Deny" } }, "ja": { "stringUnit": { "state": "translated", - "value": "未読メッセージ" + "value": "拒否" } - }, - "de": { + } + } + }, + "chat.permission.expired": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Ungelesene Nachrichten" + "value": "Expired" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Mensajes no leídos" + "value": "期限切れ" } - }, - "fr": { + } + } + }, + "chat.row.collapse.hint": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Messages non lus" + "value": "Double tap to collapse" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Messaggi non letti" + "value": "ダブルタップで折りたたむ" } - }, - "ko": { + } + } + }, + "chat.row.collapsed.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "읽지 않은 메시지" + "value": "Collapsed" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Ongelezen berichten" + "value": "折りたたみ済み" } - }, - "pl": { + } + } + }, + "chat.row.expand.hint": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Nieprzeczytane wiadomości" + "value": "Double tap to expand" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Mensagens não lidas" + "value": "ダブルタップで展開" } - }, - "ru": { + } + } + }, + "chat.row.expanded.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Непрочитанные сообщения" + "value": "Expanded" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Okunmamış mesajlar" + "value": "展開済み" } - }, - "zh-Hans": { + } + } + }, + "chat.scroll_to_bottom.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "未读消息" + "value": "Scroll to latest message" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "未讀訊息" + "value": "最新メッセージへスクロール" } } } }, - "chat.unsupported": { + "chat.status.context_compacted": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Unsupported message (%@)" + "value": "Context compacted" } }, "ja": { "stringUnit": { "state": "translated", - "value": "未対応のメッセージ(%@)" + "value": "コンテキストを圧縮しました" } - }, - "de": { + } + } + }, + "chat.status.interrupted": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Nicht unterstützte Nachricht (%@)" + "value": "Interrupted" } }, - "es": { + "ja": { "stringUnit": { "state": "translated", - "value": "Mensaje no compatible (%@)" + "value": "中断されました" } - }, - "fr": { + } + } + }, + "chat.status.session_ended": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Message non pris en charge (%@)" + "value": "Session ended" } }, - "it": { + "ja": { "stringUnit": { "state": "translated", - "value": "Messaggio non supportato (%@)" + "value": "セッション終了" } - }, - "ko": { + } + } + }, + "chat.status.session_started": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "지원되지 않는 메시지 (%@)" + "value": "Session started" } }, - "nl": { + "ja": { "stringUnit": { "state": "translated", - "value": "Niet-ondersteund bericht (%@)" + "value": "セッション開始" } - }, - "pl": { + } + } + }, + "chat.terminal.collapse.action": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Nieobsługiwana wiadomość (%@)" + "value": "Show less output" } }, - "pt-BR": { + "ja": { "stringUnit": { "state": "translated", - "value": "Mensagem não suportada (%@)" + "value": "出力を折りたたむ" } - }, - "ru": { + } + } + }, + "chat.terminal.command.accessibility": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "Неподдерживаемое сообщение (%@)" + "value": "Command %@" } }, - "tr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Desteklenmeyen mesaj (%@)" + "value": "コマンド%@" } - }, - "zh-Hans": { + } + } + }, + "chat.terminal.expand.action": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", - "value": "不支持的消息(%@)" + "value": "Show all output" } }, - "zh-Hant": { + "ja": { "stringUnit": { "state": "translated", - "value": "不支援的訊息(%@)" + "value": "すべての出力を表示" } } } }, - "chat.artifact.actions.title": { + "chat.terminal.failed.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Referenced Files" + "value": "Command %@, failed, exit code %lld" } }, "ja": { "stringUnit": { "state": "translated", - "value": "参照ファイル" + "value": "コマンド%@、失敗、終了コード%lld" } } } }, - "chat.artifact.browse_folder": { + "chat.terminal.interactive": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Browse folder" + "value": "Interactive program — open the full terminal" } }, "ja": { "stringUnit": { "state": "translated", - "value": "フォルダを表示" + "value": "インタラクティブプログラム — フルターミナルを開く" } } } }, - "chat.artifact.done": { + "chat.terminal.interactive.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Done" + "value": "%@ is an interactive program. Open the full terminal." } }, "ja": { "stringUnit": { "state": "translated", - "value": "完了" + "value": "%@はインタラクティブプログラムです。フルターミナルを開いてください。" } } } }, - "chat.artifact.file_missing.message": { + "chat.terminal.more_lines": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "The file is no longer available on your Mac." + "value": "⋯ %lld more lines" } }, "ja": { "stringUnit": { "state": "translated", - "value": "そのファイルはMac上で利用できなくなりました。" + "value": "⋯ 残り%lld行" } } } }, - "chat.artifact.file_missing.title": { + "chat.terminal.open_in_terminal": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File not found" + "value": "Open in terminal" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ファイルが見つかりません" + "value": "ターミナルで開く" } } } }, - "chat.artifact.folder.empty": { + "chat.terminal.running": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "No files" + "value": "running" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ファイルがありません" + "value": "実行中" } } } }, - "chat.artifact.folder.load_failed": { + "chat.terminal.running.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Couldn't load this folder" + "value": "Command %@, running" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このフォルダを読み込めませんでした" + "value": "コマンド%@、実行中" } } } }, - "chat.artifact.forbidden.message": { + "chat.terminal.succeeded.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This file was not referenced by the conversation." + "value": "Command %@, succeeded" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このファイルは会話で参照されていません。" + "value": "コマンド%@、成功" } } } }, - "chat.artifact.forbidden.terminal_message": { + "chat.thought.title": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This file isn't visible in the current terminal view." + "value": "Thought" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このファイルは現在のターミナル画面に表示されていません。" + "value": "思考" } } } }, - "chat.artifact.forbidden.title": { + "chat.tool.failed.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Preview unavailable" + "value": "Failed" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プレビューできません" + "value": "失敗" } } } }, - "chat.artifact.loading": { + "chat.tool.running.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Loading preview" + "value": "Running" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プレビューを読み込み中" + "value": "実行中" } } } }, - "chat.artifact.mac_unreachable.message": { + "chat.tool.succeeded.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Check the connection to your Mac and try again." + "value": "Succeeded" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macとの接続を確認して、もう一度お試しください。" + "value": "成功" } } } }, - "chat.artifact.mac_unreachable.title": { + "chat.tool.truncated": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Mac unreachable" + "value": "⋯ truncated" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Macに接続できません" + "value": "⋯ 省略" } } } }, - "chat.artifact.preview_unavailable.message": { + "chat.transcript.empty": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This file can't be previewed." + "value": "No messages yet" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このファイルはプレビューできません。" + "value": "まだメッセージはありません" } } } }, - "chat.artifact.preview_unavailable.title": { + "chat.transcript.load_failed": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Preview unavailable" + "value": "Couldn't load this conversation" } }, "ja": { "stringUnit": { "state": "translated", - "value": "プレビューできません" + "value": "この会話を読み込めませんでした" } } } }, - "chat.artifact.retry": { + "chat.transcript.retry": { "extractionState": "manual", "localizations": { "en": { @@ -6907,57 +2534,57 @@ } } }, - "chat.artifact.too_large.message": { + "chat.typing.accessibility": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "This preview is limited to %@." + "value": "Agent is working" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このプレビューの上限は%@です。" + "value": "エージェントが作業中" } } } }, - "chat.artifact.too_large.title": { + "chat.unread_separator": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "File too large to preview" + "value": "Unread messages" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ファイルが大きすぎてプレビューできません" + "value": "未読メッセージ" } } } }, - "chat.artifact.view_file": { + "chat.unsupported": { "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "View file" + "value": "Unsupported message (%@)" } }, "ja": { "stringUnit": { "state": "translated", - "value": "ファイルを表示" + "value": "未対応のメッセージ(%@)" } } } } }, "version": "1.0" -} +} \ No newline at end of file diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift index 3d4beb62ddd2..b52c44cb66b2 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Screen/ChatScreen.swift @@ -17,6 +17,7 @@ public struct ChatScreen: View { @State private var renderer = ChatMarkdownRenderer() @State private var contentCache = ChatContentCache() @State private var selectedBlockSelection: ChatBlockSelection? + @State private var selectedArtifact: ChatArtifactPathSelection? private let detailBuilder = ChatBlockDetailBuilder() @Binding private var draft: String @@ -91,6 +92,13 @@ public struct ChatScreen: View { ) } } + .navigationDestination(isPresented: artifactIsPresented) { + if let selectedArtifact { + ChatArtifactViewerDestination(path: selectedArtifact.path) { + self.selectedArtifact = nil + } + } + } .task { guard runsStoreTask else { return } await store.run() @@ -101,6 +109,15 @@ public struct ChatScreen: View { #endif } + private var artifactIsPresented: Binding { + Binding( + get: { selectedArtifact != nil }, + set: { isPresented in + if !isPresented { selectedArtifact = nil } + } + ) + } + @ViewBuilder private var errorBanner: some View { if let error = store.lastErrorDescription { @@ -296,6 +313,9 @@ public struct ChatScreen: View { store.discard(pendingID: id) }, openTerminal: onOpenTerminal, + openArtifact: { path in + selectedArtifact = ChatArtifactPathSelection(path: path) + }, showMessageDetail: { message in selectedBlockSelection = .message(id: message.id) }, diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift index 4cb98712e714..ef2b6adde562 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatBlockDetailSheetView.swift @@ -13,6 +13,7 @@ struct ChatBlockDetailSheetView: View { @Environment(\.dismiss) private var dismiss @Environment(\.chatArtifactLoader) private var artifactLoader + @State private var selectedArtifact: ChatArtifactPathSelection? init(detail: ChatBlockDetail, onOpenTerminal: (() -> Void)? = nil) { self.detail = detail @@ -33,7 +34,12 @@ struct ChatBlockDetailSheetView: View { ChatBlockDetailSectionView(section: section) } if artifactLoader.supportsArtifacts, !detail.artifactPaths.isEmpty { - ChatBlockDetailArtifactActions(paths: detail.artifactPaths) + ChatBlockDetailArtifactActions( + paths: detail.artifactPaths, + onOpenArtifact: { path in + selectedArtifact = ChatArtifactPathSelection(path: path) + } + ) } } .padding(16) @@ -62,10 +68,26 @@ struct ChatBlockDetailSheetView: View { } #endif } + .navigationDestination(isPresented: artifactIsPresented) { + if let selectedArtifact { + ChatArtifactViewerDestination(path: selectedArtifact.path) { + dismiss() + } + } + } } .accessibilityIdentifier("ChatBlockDetailSheet") } + private var artifactIsPresented: Binding { + Binding( + get: { selectedArtifact != nil }, + set: { isPresented in + if !isPresented { selectedArtifact = nil } + } + ) + } + @ViewBuilder private var openTerminalButton: some View { if let onOpenTerminal { @@ -106,14 +128,18 @@ struct ChatBlockDetailSheetView: View { private struct ChatBlockDetailArtifactActions: View { let paths: [String] + let onOpenArtifact: (String) -> Void var body: some View { VStack(alignment: .leading, spacing: 8) { - Text(String(localized: "chat.artifact.actions.title", defaultValue: "Referenced Files", bundle: .module)) + Text(String(localized: "chat.artifact.actions.title", defaultValue: "Referenced Items", bundle: .module)) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) ForEach(deduplicatedPaths, id: \.self) { path in - ChatBlockDetailArtifactActionRow(path: path) + ChatBlockDetailArtifactActionRow( + path: path, + onOpenArtifact: onOpenArtifact + ) } } } @@ -130,11 +156,7 @@ private struct ChatBlockDetailArtifactActions: View { private struct ChatBlockDetailArtifactActionRow: View { let path: String - - @Environment(\.chatArtifactLoader) private var loader - @State private var stat: ChatArtifactStat? - @State private var selectedArtifact: ChatArtifactPathSelection? - @State private var selectedFolder: ChatArtifactPathSelection? + let onOpenArtifact: (String) -> Void var body: some View { HStack(spacing: 8) { @@ -150,38 +172,17 @@ private struct ChatBlockDetailArtifactActionRow: View { .truncationMode(.middle) } Spacer(minLength: 8) - if stat?.isDirectory == true { - Button { - selectedFolder = ChatArtifactPathSelection(path: path) - } label: { - Label( - String(localized: "chat.artifact.browse_folder", defaultValue: "Browse folder", bundle: .module), - systemImage: "folder" - ) - } - .labelStyle(.iconOnly) - } else { - Button { - selectedArtifact = ChatArtifactPathSelection(path: path) - } label: { - Label( - String(localized: "chat.artifact.view_file", defaultValue: "View file", bundle: .module), - systemImage: "doc.text.magnifyingglass" - ) - } - .labelStyle(.iconOnly) + Button { + onOpenArtifact(path) + } label: { + Label( + String(localized: "chat.artifact.open_item", defaultValue: "Open item", bundle: .module), + systemImage: "doc.text.magnifyingglass" + ) } + .labelStyle(.iconOnly) } .padding(10) .background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8)) - .task(id: path) { - stat = try? await loader.stat(path: path) - } - .sheet(item: $selectedArtifact) { selection in - ChatArtifactViewerSheet(path: selection.path) - } - .sheet(item: $selectedFolder) { selection in - ChatArtifactFolderView(path: selection.path) - } } } diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift index 52b9bed98716..cb19b4c88be7 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatMessageRowView.swift @@ -69,7 +69,8 @@ public struct ChatMessageRowView: View { attachment: attachment, groupPosition: snapshot.groupPosition, showsTimestamp: snapshot.showsTimestamp, - timestamp: snapshot.message.timestamp + timestamp: snapshot.message.timestamp, + onOpenArtifact: actions.openArtifact ) case .unsupported(let payload): ChatUnsupportedRowView(payload: payload) diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift index 883100f25a2f..07b5c8f602c0 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatRowActions.swift @@ -19,6 +19,9 @@ public struct ChatRowActions { /// Opens the session's raw terminal (the escape hatch). public var openTerminal: () -> Void + /// Pushes a Mac-hosted artifact in the conversation's navigation stack. + public var openArtifact: (String) -> Void + /// Shows a non-resizing detail sheet for a compact message row. public var showMessageDetail: (ChatMessage) -> Void @@ -35,6 +38,7 @@ public struct ChatRowActions { /// - retryPending: Retries a failed pending send by pending id. /// - discardPending: Discards a failed pending send by pending id. /// - openTerminal: Opens the session's raw terminal. + /// - openArtifact: Pushes a Mac-hosted artifact path inline. /// - showMessageDetail: Presents full details for compact message rows. /// - showTerminalCommandDetail: Presents full details for terminal rows. /// - showCodeBlockDetail: Presents full details for prose code blocks. @@ -43,6 +47,7 @@ public struct ChatRowActions { retryPending: @escaping (String) -> Void = { _ in }, discardPending: @escaping (String) -> Void = { _ in }, openTerminal: @escaping () -> Void = {}, + openArtifact: @escaping (String) -> Void = { _ in }, showMessageDetail: @escaping (ChatMessage) -> Void = { _ in }, showTerminalCommandDetail: @escaping (TerminalCommandBlock) -> Void = { _ in }, showCodeBlockDetail: @escaping (String, Int) -> Void = { _, _ in } @@ -51,6 +56,7 @@ public struct ChatRowActions { self.retryPending = retryPending self.discardPending = discardPending self.openTerminal = openTerminal + self.openArtifact = openArtifact self.showMessageDetail = showMessageDetail self.showTerminalCommandDetail = showTerminalCommandDetail self.showCodeBlockDetail = showCodeBlockDetail diff --git a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift index 5cf801a87267..7d03df8cba80 100644 --- a/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift +++ b/Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/Rows/ChatAttachmentBubbleView.swift @@ -14,6 +14,7 @@ public struct ChatAttachmentBubbleView: View { private let groupPosition: ChatGroupPosition private let showsTimestamp: Bool private let timestamp: Date + private let onOpenArtifact: ((String) -> Void)? @Environment(\.chatTheme) private var theme @Environment(\.chatBubbleMaxWidth) private var bubbleMaxWidth @@ -22,7 +23,7 @@ public struct ChatAttachmentBubbleView: View { @State private var thumbnailData: Data? @State private var thumbnailFailed = false @State private var thumbnailPath: String? - @State private var selectedArtifact: ChatArtifactPathSelection? + @State private var fallbackSelection: ChatArtifactPathSelection? /// Creates an attachment bubble. /// @@ -32,16 +33,20 @@ public struct ChatAttachmentBubbleView: View { /// - showsTimestamp: Whether the group timestamp renders under this /// bubble. /// - timestamp: When the attachment was sent. + /// - onOpenArtifact: Pushes the host path inline when the caller owns a + /// navigation stack. When omitted, the standalone bubble uses a sheet. public init( attachment: ChatAttachment, groupPosition: ChatGroupPosition, showsTimestamp: Bool, - timestamp: Date + timestamp: Date, + onOpenArtifact: ((String) -> Void)? = nil ) { self.attachment = attachment self.groupPosition = groupPosition self.showsTimestamp = showsTimestamp self.timestamp = timestamp + self.onOpenArtifact = onOpenArtifact } public var body: some View { @@ -60,7 +65,7 @@ public struct ChatAttachmentBubbleView: View { .accessibilityElement(children: .combine) } .frame(maxWidth: .infinity, alignment: .trailing) - .sheet(item: $selectedArtifact) { selection in + .sheet(item: $fallbackSelection) { selection in ChatArtifactViewerSheet(path: selection.path) } } @@ -69,7 +74,11 @@ public struct ChatAttachmentBubbleView: View { private var artifactAwareBubble: some View { if artifactLoader.supportsArtifacts, let hostPath = attachment.hostPath, !hostPath.isEmpty { Button { - selectedArtifact = ChatArtifactPathSelection(path: hostPath) + if let onOpenArtifact { + onOpenArtifact(hostPath) + } else { + fallbackSelection = ChatArtifactPathSelection(path: hostPath) + } } label: { if thumbnailFailed { bubble diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ArtifactStreamCallCounter.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ArtifactStreamCallCounter.swift new file mode 100644 index 000000000000..51dbe8aba0e4 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ArtifactStreamCallCounter.swift @@ -0,0 +1,12 @@ +/// Records whether an artifact stream was invoked without shared test globals. +actor ArtifactStreamCallCounter { + private var count = 0 + + func recordCall() { + count += 1 + } + + func callCount() -> Int { + count + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift new file mode 100644 index 000000000000..e50daf415b6f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactContentCacheTests.swift @@ -0,0 +1,145 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Chat artifact content cache") +struct ChatArtifactContentCacheTests { + @Test("mtime and size changes invalidate the content key") + func keyInvalidation() throws { + let base = try #require(ChatArtifactContentCache.key( + scopeKey: "chat:session", + path: "/tmp/report.txt", + modifiedAt: Date(timeIntervalSince1970: 10), + size: 3 + )) + let changedMTime = try #require(ChatArtifactContentCache.key( + scopeKey: "chat:session", + path: "/tmp/report.txt", + modifiedAt: Date(timeIntervalSince1970: 11), + size: 3 + )) + let changedSize = try #require(ChatArtifactContentCache.key( + scopeKey: "chat:session", + path: "/tmp/report.txt", + modifiedAt: Date(timeIntervalSince1970: 10), + size: 4 + )) + + #expect(base != changedMTime) + #expect(base != changedSize) + } + + @Test("disk budget evicts the least recently used content") + func diskLRUEviction() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChatArtifactContentCache( + directory: directory, + maxDiskBytes: 6, + maxMemoryBytes: 0 + ) + let source = CountingContentSource(values: [ + "a": Data([1, 1, 1]), + "b": Data([2, 2, 2]), + "c": Data([3, 3, 3]), + ]) + + try await stream(key: "a", at: 1, cache: cache, source: source) + try await stream(key: "b", at: 2, cache: cache, source: source) + try await stream(key: "a", at: 3, cache: cache, source: source) + try await stream(key: "c", at: 4, cache: cache, source: source) + try await stream(key: "a", at: 5, cache: cache, source: source) + try await stream(key: "b", at: 6, cache: cache, source: source) + + #expect(await source.fetchCount(for: "a") == 1) + #expect(await source.fetchCount(for: "b") == 2) + #expect(await source.fetchCount(for: "c") == 1) + } + + @Test("reopening unchanged viewer content performs no second fetch") + @MainActor + func viewerInstantHit() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let source = CountingContentSource(values: ["artifact": Data("hello".utf8)]) + let modifiedAt = Date(timeIntervalSince1970: 100) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + scope: .chat(sessionID: "session"), + contentCache: ChatArtifactContentCache(directory: directory), + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: 5, + modifiedAt: modifiedAt, + kind: .text, + mimeType: "text/plain" + ) + }, + fetch: { _, _ in Data("hello".utf8) }, + stream: { _, receive in + try await source.fetch(key: "artifact", receive: receive) + } + ) + + let first = ChatArtifactViewerModel() + await first.load(path: "/tmp/artifact.txt", loader: loader) + let second = ChatArtifactViewerModel() + await second.load(path: "/tmp/artifact.txt", loader: loader) + + #expect(first.renderedText == "hello") + #expect(second.renderedText == "hello") + #expect(await source.fetchCount(for: "artifact") == 1) + } + + @Test("unsupported loader scopes never share cached bytes") + func unsupportedScopeBypassesCache() async throws { + let directory = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let bytes = Data("fixture".utf8) + let source = CountingContentSource(values: ["fixture": bytes]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + contentCache: ChatArtifactContentCache(directory: directory), + stream: { _, receive in + try await source.fetch(key: "fixture", receive: receive) + } + ) + + for _ in 0..<2 { + try await loader.stream( + path: "/tmp/fixture.txt", + modifiedAt: Date(timeIntervalSince1970: 100), + size: Int64(bytes.count), + onChunk: { _ in } + ) + } + + #expect(await source.fetchCount(for: "fixture") == 2) + } + + private func stream( + key: String, + at seconds: TimeInterval, + cache: ChatArtifactContentCache, + source: CountingContentSource + ) async throws { + _ = try await cache.stream( + for: key, + expectedSize: 3, + accessedAt: Date(timeIntervalSince1970: seconds), + fetch: { receive in + try await source.fetch(key: key, receive: receive) + }, + receive: { _ in } + ) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-content-cache-\(UUID().uuidString)", isDirectory: true) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFileActionStoreTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFileActionStoreTests.swift new file mode 100644 index 000000000000..6aa742f6cb1e --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactFileActionStoreTests.swift @@ -0,0 +1,62 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Chat artifact file actions") +struct ChatArtifactFileActionStoreTests { + @Test("materialized files retain their source name and reuse cached bytes") + func materializeNamedCachedFile() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-file-action-tests-\(UUID().uuidString)", + isDirectory: true + ) + defer { try? FileManager.default.removeItem(at: root) } + let cacheDirectory = root.appendingPathComponent("cache", isDirectory: true) + let actionDirectory = root.appendingPathComponent("actions", isDirectory: true) + let bytes = Data("quarterly results".utf8) + let modifiedAt = Date(timeIntervalSince1970: 123) + let source = CountingContentSource(values: ["report": bytes]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + scope: .chat(sessionID: "session"), + contentCache: ChatArtifactContentCache(directory: cacheDirectory), + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(bytes.count), + modifiedAt: modifiedAt, + kind: .text, + mimeType: "text/plain" + ) + }, + fetch: { _, _ in bytes }, + stream: { _, receive in + try await source.fetch(key: "report", receive: receive) + } + ) + let store = ChatArtifactFileActionStore(directory: actionDirectory) + + let first = try await store.materialize( + path: "/Users/example/Quarterly Report.txt", + loader: loader + ) + let second = try await store.materialize( + path: "/Users/example/Quarterly Report.txt", + loader: loader + ) + + #expect(first.lastPathComponent == "Quarterly Report.txt") + #expect(second.lastPathComponent == "Quarterly Report.txt") + #expect(try Data(contentsOf: first) == bytes) + #expect(try Data(contentsOf: second) == bytes) + #expect(await source.fetchCount(for: "report") == 1) + + await store.remove(first) + await store.remove(second) + #expect(!FileManager.default.fileExists(atPath: first.path)) + #expect(!FileManager.default.fileExists(atPath: second.path)) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLineIndexTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLineIndexTests.swift new file mode 100644 index 000000000000..54c98e512eac --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLineIndexTests.swift @@ -0,0 +1,36 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact line index") +struct ChatArtifactLineIndexTests { + @Test("line starts accumulate incrementally in UTF-16 offsets") + func appendsLineStarts() { + var index = ChatArtifactLineIndex() + + index.append("one\n🙂") + #expect(index.lineStartOffsets == [0, 4]) + #expect(index.loadedUTF16Length == 6) + index.append("\nthree\n") + + #expect(index.lineStartOffsets == [0, 4, 7, 13]) + #expect(index.loadedUTF16Length == 13) + #expect(index.lineCount == 4) + #expect(index.lineNumber(containingUTF16Offset: 8) == 3) + } + + @Test("go to line clamps against lines loaded so far") + func clampsDuringStreaming() { + var index = ChatArtifactLineIndex() + index.append("first\nsecond") + + #expect(index.clampedLine(-4) == 1) + #expect(index.offset(forLine: -4) == 0) + #expect(index.clampedLine(200) == 2) + #expect(index.offset(forLine: 200) == 6) + + index.append("\nthird") + #expect(index.clampedLine(3) == 3) + #expect(index.offset(forLine: 3) == 13) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift index 2f078480e938..e4a20d201f69 100644 --- a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactLoaderTests.swift @@ -5,6 +5,20 @@ import Testing @testable import CmuxAgentChatUI struct ChatArtifactLoaderTests { + @Test func directoryRoutingRequiresTheNewFolderCapability() { + let directory = ChatArtifactStat( + exists: true, + isDirectory: true, + size: 0, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .directory, + mimeType: nil + ) + + #expect(directory.showsFolder(supportsDirectoryBrowsing: true)) + #expect(!directory.showsFolder(supportsDirectoryBrowsing: false)) + } + @Test func thumbnailCacheReusesSamePathAndDimension() async throws { let source = CountingArtifactSource() let loader = ChatArtifactLoader( @@ -44,6 +58,9 @@ struct ChatArtifactLoaderTests { }, thumbnail: { path, maxDimension in try await terminalSource.thumbnail(path: path, maxDimension: maxDimension) + }, + list: { path in + try await terminalSource.list(path: path) } ) @@ -55,6 +72,45 @@ struct ChatArtifactLoaderTests { #expect(await terminalSource.thumbnailRequestCount() == 1) #expect(try await terminalLoader.stat(path: "/tmp/image.png").kind == .image) } + + @Test func terminalListCapabilityRoutesAndFailsClosedWithoutCallingHandler() async throws { + let source = CountingTerminalArtifactSource() + let supported = ChatArtifactLoader( + terminalWorkspaceID: "workspace-1", + terminalSurfaceID: "surface-1", + supportsArtifacts: true, + supportsDirectoryBrowsing: true, + stat: { path in try await source.stat(path: path) }, + fetch: { path, progress in try await source.fetch(path: path, progress: progress) }, + thumbnail: { path, dimension in + try await source.thumbnail(path: path, maxDimension: dimension) + }, + list: { path in try await source.list(path: path) } + ) + let unsupported = ChatArtifactLoader( + terminalWorkspaceID: "workspace-1", + terminalSurfaceID: "surface-1", + supportsArtifacts: true, + stat: { path in try await source.stat(path: path) }, + fetch: { path, progress in try await source.fetch(path: path, progress: progress) }, + thumbnail: { path, dimension in + try await source.thumbnail(path: path, maxDimension: dimension) + }, + list: { path in try await source.list(path: path) } + ) + + #expect(try await supported.list(path: "/tmp/folder").entries.first?.name == "child.txt") + #expect(await source.listRequestCount() == 1) + do { + _ = try await unsupported.list(path: "/tmp/folder") + Issue.record("unsupported loader should not issue a list request") + } catch let error as ChatArtifactError { + #expect(error == .unsupported) + } catch { + Issue.record("unexpected error: \(error)") + } + #expect(await source.listRequestCount() == 1) + } } private actor CountingArtifactSource: ChatEventSource { @@ -115,11 +171,16 @@ private actor CountingArtifactSource: ChatEventSource { private actor CountingTerminalArtifactSource { private var thumbnails = 0 + private var lists = 0 func thumbnailRequestCount() -> Int { thumbnails } + func listRequestCount() -> Int { + lists + } + func stat(path: String) async throws -> ChatArtifactStat { ChatArtifactStat( exists: true, @@ -142,4 +203,11 @@ private actor CountingTerminalArtifactSource { thumbnails += 1 return ChatArtifactThumbnail(data: Data([7, 8, 9]), pixelWidth: maxDimension, pixelHeight: maxDimension) } + + func list(path: String) async throws -> ChatArtifactDirectoryListing { + lists += 1 + return ChatArtifactDirectoryListing(entries: [ + ChatArtifactDirectoryEntry(name: "child.txt", isDirectory: false, size: 1, kind: .text), + ]) + } } diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownDocumentTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownDocumentTests.swift new file mode 100644 index 000000000000..d186339aad17 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownDocumentTests.swift @@ -0,0 +1,42 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact Markdown document") +struct ChatArtifactMarkdownDocumentTests { + @Test("preserves headings paragraphs lists code and table rows as distinct blocks") + func documentStructure() { + let document = ChatArtifactMarkdownDocument(markdown: """ + # Guide + + Intro with **bold** text. + + - one + 2. two + + ```swift + print("hi") + ``` + + | kind | file | + | --- | --- | + | log | build.log | + | data | data.csv | + """) + + #expect(document.blocks.map(\.kind) == [ + .heading(level: 1), + .paragraph, + .bullet(indent: 0), + .ordered(marker: "2.", indent: 0), + .code(language: "swift"), + .tableRow(isHeader: true), + .tableRow(isHeader: false), + .tableRow(isHeader: false), + ]) + #expect(document.blocks[0].text == "Guide") + #expect(document.blocks[4].text == "print(\"hi\")") + #expect(document.blocks[5].text == "kind │ file") + #expect(document.blocks[7].text == "data │ data.csv") + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownPresentationTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownPresentationTests.swift new file mode 100644 index 000000000000..4df61116dc78 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactMarkdownPresentationTests.swift @@ -0,0 +1,28 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact Markdown presentation") +struct ChatArtifactMarkdownPresentationTests { + @Test("small files toggle between rendered and raw") + func togglesSmallFile() { + var presentation = ChatArtifactMarkdownPresentation(byteCount: 1_500_000) + + #expect(presentation.isRenderedAvailable) + #expect(presentation.mode == .rendered) + presentation.select(.raw) + #expect(presentation.mode == .raw) + presentation.select(.rendered) + #expect(presentation.mode == .rendered) + } + + @Test("oversize files remain raw") + func oversizeFallsBackToRaw() { + var presentation = ChatArtifactMarkdownPresentation(byteCount: 1_500_001) + + #expect(!presentation.isRenderedAvailable) + #expect(presentation.mode == .raw) + presentation.select(.rendered) + #expect(presentation.mode == .raw) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift new file mode 100644 index 000000000000..e94f51a3744b --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPresentationBoundaryTests.swift @@ -0,0 +1,130 @@ +import CmuxAgentChat +import Foundation +import Testing + +#if canImport(UIKit) +import UIKit +#endif + +@testable import CmuxAgentChatUI + +@Suite("Artifact presentation boundaries") +struct ChatArtifactPresentationBoundaryTests { + @Test("rapid completed transitions preserve one unique selected path") + func rapidPageTransitions() { + let allPaths = ["/notes.md", "/data.csv", "/build.log"] + var state = ChatArtifactPageControllerState( + paths: allPaths, + selectedPath: allPaths[0] + ) + + for index in 0..<60 { + let next = allPaths[index.isMultiple(of: 2) ? 1 : 0] + let didComplete = state.completeTransition(to: next) + #expect(didComplete) + state.update(paths: allPaths + [allPaths[1]], selectedPath: next) + #expect(state.paths == allPaths) + #expect(state.selectedPath == next) + } + + #expect(state.path(before: "/data.csv") == "/notes.md") + #expect(state.path(after: "/data.csv") == "/build.log") + let didCompleteUnknown = state.completeTransition(to: "/unknown") + #expect(!didCompleteUnknown) + } + + @Test("completed selection reports an expanded neighbor topology exactly once") + func pageTopologyReload() { + let notes = "/notes.md" + let data = "/data.csv" + let log = "/build.log" + var state = ChatArtifactPageControllerState( + paths: [notes, data], + selectedPath: notes + ) + + let didComplete = state.completeTransition(to: data) + #expect(didComplete) + let didExpandPaths = state.update(paths: [notes, data, log], selectedPath: data) + #expect(didExpandPaths) + #expect(state.path(after: data) == log) + let didChangeDeduplicatedPaths = state.update( + paths: [notes, data, log, data], + selectedPath: data + ) + #expect(!didChangeDeduplicatedPaths) + } + + @Test("large skipped-highlight updates need no full TextKit snapshot") + func textUpdatePlanning() { + let streaming = ChatArtifactTextUpdatePlan( + reachedEOF: false, + highlightDecision: .skippedForSize, + searchQuery: "" + ) + let finished = ChatArtifactTextUpdatePlan( + reachedEOF: true, + highlightDecision: .skippedForSize, + searchQuery: "" + ) + let searched = ChatArtifactTextUpdatePlan( + reachedEOF: false, + highlightDecision: .skippedForSize, + searchQuery: "error" + ) + + #expect(!streaming.requiresFullTextSnapshot) + #expect(!finished.requiresFullTextSnapshot) + #expect(searched.requiresFullTextSnapshot) + } + + @Test("accessibility projection remains a single bounded excerpt") + func boundedAccessibilityContent() { + var content = ChatArtifactTextAccessibilityContent() + content.append(String(repeating: "x", count: 100_000)) + content.append("tail") + + #expect(content.excerpt.count == ChatArtifactTextAccessibilityContent.maximumCharacterCount) + #expect(content.isTruncated) + #expect(!content.excerpt.contains("tail")) + } + + #if canImport(UIKit) + @Test("large text remains interactive behind one bounded accessibility element") + @MainActor + func textContainerInteractionAndAccessibility() { + let container = ChatArtifactTextContainerView() + + #expect(container.isUserInteractionEnabled) + #expect(container.textView.isUserInteractionEnabled) + #expect(container.textView.isScrollEnabled) + #expect(container.isAccessibilityElement) + #expect(!container.textView.isAccessibilityElement) + #expect(container.textView.accessibilityElementsHidden) + } + #endif + + @Test("child routes retain the live loader and exact descendant path") + func folderChildLoaderRouting() async throws { + let expected = ChatArtifactDirectoryListing(entries: []) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + supportsDirectoryBrowsing: true, + scope: .terminal(workspaceID: "workspace", surfaceID: "surface"), + list: { path in + #expect(path == "/project/docs") + return expected + } + ) + let route = ChatArtifactFolderRoute( + parentPath: "/project", + childName: "docs", + scope: .terminal, + loader: loader + ) + + #expect(route.path == "/project/docs") + #expect(route.loader.scope == loader.scope) + #expect(try await route.loader.list(path: route.path) == expected) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift new file mode 100644 index 000000000000..171ced75e6c9 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactPreviewRouterTests.swift @@ -0,0 +1,59 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact preview routing") +struct ChatArtifactPreviewRouterTests { + @Test + func routesMimeAndExtensionMatrix() { + let fixtures: [(String, ChatArtifactKind, String?, Bool, ChatArtifactPreviewRoute)] = [ + ("/tmp/photo.png", .image, "image/png", false, .image), + ("/tmp/image-with-pdf-name.pdf", .image, "application/pdf", false, .image), + ("/tmp/report", .binary, "application/pdf; charset=binary", false, .pdf), + ("/tmp/report.PDF", .binary, "application/octet-stream", false, .pdf), + ("/tmp/movie.mp4", .binary, "video/mp4", false, .media), + ("/tmp/sound.m4a", .binary, "audio/mp4", false, .media), + ("/tmp/recording", .binary, "video/mp4", false, .media), + ("/tmp/voice-note", .binary, "audio/mp4; charset=binary", false, .media), + ("/tmp/README.md", .text, "text/markdown", false, .markdown), + ("/tmp/notes.MARKDOWN", .text, "text/plain", false, .markdown), + ("/tmp/plain.txt", .text, "text/plain", false, .text), + ("/tmp/output", .text, nil, false, .text), + ("/tmp/report.docx", .binary, nil, false, .quickLook), + ("/tmp/sheet.xlsx", .binary, nil, false, .quickLook), + ("/tmp/slides.pptx", .binary, nil, false, .quickLook), + ("/tmp/document.pages", .binary, nil, false, .quickLook), + ("/tmp/deck.key", .binary, nil, false, .quickLook), + ("/tmp/table.numbers", .binary, nil, false, .quickLook), + ("/tmp/archive.zip", .binary, nil, false, .binary), + ("/tmp/blob.bin", .binary, nil, false, .binary), + ("/tmp/folder", .directory, nil, true, .folder), + ] + let router = ChatArtifactPreviewRouter() + + for (path, kind, mimeType, isDirectory, expected) in fixtures { + let stat = ChatArtifactStat( + exists: true, + isDirectory: isDirectory, + size: 12, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: kind, + mimeType: mimeType + ) + + #expect(router.route(stat: stat, path: path) == expected, "Unexpected route for \(path)") + } + } + + @Test + func mimeFallbackExtensionTypesExtensionlessTempFiles() { + let router = ChatArtifactPreviewRouter() + + #expect(router.preferredExtension(forMIMEType: "video/mp4") == "mp4") + #expect(router.preferredExtension(forMIMEType: "application/pdf; charset=binary") == "pdf") + #expect(router.preferredExtension(forMIMEType: "not a mime") == nil) + #expect(router.preferredExtension(forMIMEType: nil) == nil) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSearchModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSearchModelTests.swift new file mode 100644 index 000000000000..ec7d7ec8c6ea --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSearchModelTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text search") +struct ChatArtifactSearchModelTests { + @Test("literal case-insensitive search returns UTF-16 ranges") + func findsLiteralRanges() { + let model = ChatArtifactSearchModel( + query: "A.B", + text: "a.b A-B A.B a.b" + ) + + #expect(model.matchRanges == [ + NSRange(location: 0, length: 3), + NSRange(location: 8, length: 3), + NSRange(location: 12, length: 3), + ]) + #expect(model.summary == ChatArtifactSearchSummary(currentPosition: 1, matchCount: 3)) + } + + @Test("next and previous wrap around") + func navigationWraps() { + var model = ChatArtifactSearchModel(query: "hit", text: "hit no hit") + + model.selectPrevious() + #expect(model.summary.currentPosition == 2) + model.selectNext() + #expect(model.summary.currentPosition == 1) + model.selectNext() + #expect(model.summary.currentPosition == 2) + model.selectNext() + #expect(model.summary.currentPosition == 1) + } + + @Test("stream append recomputes matches and preserves selection") + func recomputesAfterAppend() { + var model = ChatArtifactSearchModel(query: "line", text: "line one\nline two") + model.selectNext() + let selectedRange = model.currentRange + + model.recompute(in: "line one\nline two\nLINE three") + + #expect(model.matchRanges.count == 3) + #expect(model.currentRange == selectedRange) + #expect(model.summary == ChatArtifactSearchSummary(currentPosition: 2, matchCount: 3)) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSyntaxHighlightPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSyntaxHighlightPolicyTests.swift new file mode 100644 index 000000000000..00040496d89c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactSyntaxHighlightPolicyTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact syntax highlighting policy") +struct ChatArtifactSyntaxHighlightPolicyTests { + private let policy = ChatArtifactSyntaxHighlightPolicy() + + @Test("known languages highlight through the exact size limit") + func highlightsAtThreshold() { + #expect(policy.decision( + path: "/tmp/example.swift", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + ) == .highlight(language: "swift")) + } + + @Test("files over the limit skip for size and show the pill") + func skipsOverThreshold() { + let decision = policy.decision( + path: "/tmp/example.swift", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + 1 + ) + + #expect(decision == .skippedForSize) + #expect(decision.showsHighlightingOffPill) + } + + @Test("unknown small files use detection without showing the pill") + func detectsOnlySmallUnknownFiles() { + let small = policy.decision( + path: "/tmp/extensionless", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxAutomaticDetectionBytes - 1 + ) + let larger = policy.decision( + path: "/tmp/server.log", + byteCount: ChatArtifactSyntaxHighlightPolicy.maxAutomaticDetectionBytes + ) + + #expect(small == .highlight(language: nil)) + #expect(!small.showsHighlightingOffPill) + #expect(larger == .skippedNoLanguage) + #expect(!larger.showsHighlightingOffPill) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift new file mode 100644 index 000000000000..07ea27dd5f29 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTemporaryFileStoreTests.swift @@ -0,0 +1,99 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact temporary files") +struct ChatArtifactTemporaryFileStoreTests { + @Test("writes streamed chunks to a file with the requested extension") + func writesChunks() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-temp-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let first = Data("PDF".utf8) + let second = Data(" bytes".utf8) + let totalSize = Int64(first.count + second.count) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: first, + offset: 0, + totalSize: totalSize, + eof: false + )) + try await onChunk(ChatArtifactChunk( + data: second, + offset: Int64(first.count), + totalSize: totalSize, + eof: true + )) + } + ) + let store = ChatArtifactTemporaryFileStore(directory: directory) + + let fileURL = try await store.fetch( + path: "/remote/report", + expectedSize: totalSize, + limit: 1024, + fallbackExtension: "pdf", + loader: loader, + progress: { _ in } + ) + + #expect(fileURL.pathExtension == "pdf") + #expect(try Data(contentsOf: fileURL) == first + second) + await store.remove(fileURL) + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test("cancellation removes a partially written file") + func cancellationRemovesPartialFile() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-cancel-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let first = Data("partial".utf8) + let second = Data(" remainder".utf8) + let totalSize = Int64(first.count + second.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk(data: first, offset: 0, totalSize: totalSize, eof: false), + ChatArtifactChunk( + data: second, + offset: Int64(first.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stream: { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + ) + let store = ChatArtifactTemporaryFileStore(directory: directory) + let fetchTask = Task { + try await store.fetch( + path: "/remote/movie.mp4", + expectedSize: totalSize, + limit: 512 * 1024 * 1024, + loader: loader, + progress: { _ in } + ) + } + + await stream.waitUntilFirstChunkDelivered() + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).count == 1) + fetchTask.cancel() + await stream.waitUntilCancelled() + do { + _ = try await fetchTask.value + Issue.record("cancelled fetch should throw") + } catch is CancellationError { + // Expected structured cancellation. + } catch { + Issue.record("unexpected error: \(error)") + } + #expect(try FileManager.default.contentsOfDirectory(atPath: directory.path).isEmpty) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift new file mode 100644 index 000000000000..478203660323 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextAppendPolicyTests.swift @@ -0,0 +1,40 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text append policy") +struct ChatArtifactTextAppendPolicyTests { + @Test("coalesces tracked and decelerating appends until scrolling becomes idle") + func defersUserScrollAppends() { + var policy = ChatArtifactTextAppendPolicy() + + #expect(policy.enqueue(chunkCount: 1) == 1) + policy.beginTracking() + #expect(policy.enqueue(chunkCount: 2) == 0) + #expect(policy.endTracking(willDecelerate: true) == 0) + #expect(policy.enqueue(chunkCount: 3) == 0) + #expect(policy.endDecelerating() == 5) + } + + @Test("programmatic scrolls never defer appends") + func programmaticScrollAppendsApplyImmediately() { + // Deferral during coordinator-owned scrolls once stranded every later + // chunk when an end-of-animation callback was missed; animated jumps + // are protected by convergence re-targeting instead. + var policy = ChatArtifactTextAppendPolicy() + + policy.beginProgrammaticAnimation() + #expect(policy.enqueue(chunkCount: 2) == 2) + #expect(policy.endProgrammaticAnimation() == 0) + } + + @Test("a missed end-of-animation callback cannot strand chunks") + func missedAnimationEndDoesNotStrandChunks() { + var policy = ChatArtifactTextAppendPolicy() + + policy.beginProgrammaticAnimation() + // No endProgrammaticAnimation is ever delivered. + #expect(policy.enqueue(chunkCount: 3) == 3) + #expect(!policy.isDeferring) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift new file mode 100644 index 000000000000..c889bdc1eed6 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextBottomPinStateMachineTests.swift @@ -0,0 +1,217 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text bottom pin") +struct ChatArtifactTextBottomPinStateMachineTests { + @Test("End engages a durable bottom pin with one initial animation") + func endEngagesBottomPin() { + var pin = ChatArtifactTextBottomPinStateMachine() + let boundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 149_188 + ) + + #expect( + pin.engage(target: .end, boundary: boundary) + == .scrollToBottom(boundary: boundary, animated: true) + ) + #expect(pin.isPinned) + #expect(pin.target == .end) + #expect(pin.phase == .initialAnimation) + } + + @Test("a pinned view re-pins non-animated across late layout growth") + func repinsAcrossLayoutGrowth() { + var pin = ChatArtifactTextBottomPinStateMachine() + let earlyBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 149_188 + ) + let refinedBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .end, boundary: earlyBoundary) + #expect( + pin.initialAnimationSettled(at: earlyBoundary) + == .scrollToBottom(boundary: earlyBoundary, animated: false) + ) + pin.didApplyPin(at: earlyBoundary) + + #expect( + pin.layoutChanged(to: refinedBoundary) + == .scrollToBottom(boundary: refinedBoundary, animated: false) + ) + pin.didApplyPin(at: refinedBoundary) + #expect(pin.visibleBoundary == refinedBoundary) + } + + @Test("user interaction exits the pin and later layout cannot re-engage it") + func userInteractionExitsPin() { + var pin = ChatArtifactTextBottomPinStateMachine() + let boundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .end, boundary: boundary) + _ = pin.initialAnimationSettled(at: boundary) + pin.didApplyPin(at: boundary) + pin.userInteracted() + + #expect(!pin.isPinned) + #expect(pin.layoutChanged(to: boundary) == .none) + #expect(pin.visibleBoundary == nil) + } + + @Test("streaming follow-tail finishes with the visible boundary at storage end") + func terminalStateShowsStorageEnd() { + var pin = ChatArtifactTextBottomPinStateMachine() + let partialBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 120_000, + contentOffsetY: 112_000 + ) + let finalBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .latest, boundary: partialBoundary) + _ = pin.initialAnimationSettled(at: partialBoundary) + pin.didApplyPin(at: partialBoundary) + + #expect( + pin.appendsFlushed(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + #expect( + pin.reachedEOF(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + + #expect(pin.target == .end) + #expect(pin.phase == .following) + #expect(pin.visibleBoundary?.storageEnd == finalBoundary.storageEnd) + } + + @Test("EOF during the initial animation is retained for the terminal settle") + func eofDuringInitialAnimationIsRetained() { + var pin = ChatArtifactTextBottomPinStateMachine() + let partialBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 120_000, + contentOffsetY: 112_000 + ) + let finalBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .latest, boundary: partialBoundary) + let didMarkEOF = pin.markReachedEOF() + #expect(didMarkEOF) + #expect(pin.target == .end) + #expect( + pin.initialAnimationSettled(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + + #expect(pin.visibleBoundary == finalBoundary) + } + + @Test("EOF plus the final deferred append leaves the pinned document end visible") + func finalDeferredAppendLeavesDocumentEndVisible() { + var appendPolicy = ChatArtifactTextAppendPolicy() + var pin = ChatArtifactTextBottomPinStateMachine() + let preFlushBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 171_817, + contentOffsetY: 165_817 + ) + let finalBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + _ = pin.engage(target: .latest, boundary: preFlushBoundary) + appendPolicy.beginProgrammaticAnimation() + // Appends apply immediately even while the pin's own scroll animates, + // so a missed end-of-animation callback can no longer truncate the + // storage below the file end. + #expect(appendPolicy.enqueue(chunkCount: 1) == 1) + let didReachEOF = pin.markReachedEOF() + #expect(didReachEOF) + + #expect(appendPolicy.endProgrammaticAnimation() == 0) + #expect( + pin.appendsFlushed(at: finalBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: true) + ) + #expect( + pin.initialAnimationSettled(at: preFlushBoundary) + == .scrollToBottom(boundary: finalBoundary, animated: false) + ) + pin.didApplyPin(at: finalBoundary) + + #expect(!appendPolicy.isDeferring) + #expect(pin.target == .end) + #expect(pin.visibleBoundary == finalBoundary) + } + + @Test("cold-open End re-arms across immediate appends before concluding at the visible end") + func coldOpenEndRearmsAcrossImmediateAppends() { + var appendPolicy = ChatArtifactTextAppendPolicy() + var pin = ChatArtifactTextBottomPinStateMachine() + let lineOneBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 1_024, + contentOffsetY: 0 + ) + let streamedBoundary = ChatArtifactTextBottomBoundary( + storageEnd: 174_000, + contentOffsetY: 168_000 + ) + + #expect( + pin.engage(target: .latest, boundary: lineOneBoundary) + == .scrollToBottom(boundary: lineOneBoundary, animated: true) + ) + appendPolicy.beginProgrammaticAnimation() + + // Commit 62f57bab4a deliberately applies these chunks immediately. + #expect(appendPolicy.enqueue(chunkCount: 3) == 3) + #expect( + pin.appendsFlushed(at: streamedBoundary) + == .scrollToBottom(boundary: streamedBoundary, animated: true) + ) + #expect(pin.phase == .initialAnimation) + + var convergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: streamedBoundary.contentOffsetY + ) + #expect( + convergence.decision( + observedOffset: lineOneBoundary.contentOffsetY, + targetOffset: streamedBoundary.contentOffsetY + ) == .retarget(offset: streamedBoundary.contentOffsetY) + ) + #expect(pin.phase == .initialAnimation) + + // A settle callback cannot conclude the pin until the requested bottom + // boundary is genuinely visible. + #expect( + pin.initialAnimationSettled( + at: streamedBoundary, + isBoundaryVisible: false + ) == .scrollToBottom(boundary: streamedBoundary, animated: false) + ) + #expect(pin.phase == .initialAnimation) + pin.didApplyPin(at: streamedBoundary) + + #expect(appendPolicy.endProgrammaticAnimation() == 0) + #expect(pin.phase == .following) + #expect(pin.visibleBoundary == streamedBoundary) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextEndJumpTargetTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextEndJumpTargetTests.swift new file mode 100644 index 000000000000..bb31e0143977 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextEndJumpTargetTests.swift @@ -0,0 +1,12 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text end jump target") +struct ChatArtifactTextEndJumpTargetTests { + @Test("streaming targets latest and EOF targets the final end") + func followsStreamingState() { + #expect(ChatArtifactTextEndJumpTarget(reachedEOF: false) == .latest) + #expect(ChatArtifactTextEndJumpTarget(reachedEOF: true) == .end) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift new file mode 100644 index 000000000000..a10f6efd8acb --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextJumpConvergenceTests.swift @@ -0,0 +1,67 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text jump convergence") +struct ChatArtifactTextJumpConvergenceTests { + @Test("retargets a stale laid-out window until the requested boundary is stable") + func retargetsUntilStable() { + var top = ChatArtifactTextJumpConvergence(initialTargetOffset: 0) + #expect(top.decision(observedOffset: 149_188, targetOffset: 0) == .retarget(offset: 0)) + #expect(top.decision(observedOffset: 0, targetOffset: 0) == .finish) + + var end = ChatArtifactTextJumpConvergence(initialTargetOffset: 19_200) + #expect(end.decision(observedOffset: 19_200, targetOffset: 168_000) == .retarget(offset: 168_000)) + #expect(end.decision(observedOffset: 168_000, targetOffset: 168_000) == .finish) + } + + @Test("forces the latest boundary after the bounded retarget budget") + func boundsRetargeting() { + var convergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: 20_000, + maximumRetargetCount: 1 + ) + + #expect(convergence.decision(observedOffset: 20_000, targetOffset: 40_000) == .retarget(offset: 40_000)) + #expect(convergence.decision(observedOffset: 40_000, targetOffset: 80_000) == .force(offset: 80_000)) + } + + @Test("a cold-open synchronous settle callback cannot recurse beyond the budget") + func coldOpenSynchronousSettleCallbackCannotExceedBudget() { + let maximumRetargetCount = 2 + let recursionSafetyLimit = 64 + var convergence = ChatArtifactTextJumpConvergence( + initialTargetOffset: 0, + maximumRetargetCount: maximumRetargetCount + ) + var callbackCount = 0 + var forcedBoundaryMaterializationCount = 0 + + func settle(targetOffset: Double) { + guard callbackCount < recursionSafetyLimit else { return } + callbackCount += 1 + + switch convergence.decision( + observedOffset: 0, + targetOffset: targetOffset + ) { + case .finish: + return + case .retarget(let offset): + // An empty cold-open layout window can synchronously complete + // the retarget while discovering a later document boundary. + settle(targetOffset: offset + 20_000) + case .force(let offset): + forcedBoundaryMaterializationCount += 1 + // UITextView can synchronously report animation completion + // while the bounded final-character range is materialized. + settle(targetOffset: offset + 20_000) + } + } + + settle(targetOffset: 20_000) + + #expect(callbackCount <= maximumRetargetCount + 2) + #expect(forcedBoundaryMaterializationCount == 1) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextPreferencesTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextPreferencesTests.swift new file mode 100644 index 000000000000..54af0643769f --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactTextPreferencesTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact text preferences", .serialized) +struct ChatArtifactTextPreferencesTests { + @Test("log defaults differ and explicit wrap choices persist per kind") + func wrapPersistence() throws { + let suiteName = "ChatArtifactTextPreferencesTests.wrap.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + + #expect(!preferences.wrapsLines(for: .log)) + #expect(preferences.wrapsLines(for: .code)) + #expect(preferences.wrapsLines(for: .plainText)) + + preferences.setWrapsLines(true, for: .log) + preferences.setWrapsLines(false, for: .code) + let reloaded = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + #expect(reloaded.wrapsLines(for: .log)) + #expect(!reloaded.wrapsLines(for: .code)) + #expect(reloaded.wrapsLines(for: .plainText)) + } + + @Test("font sizes persist independently and clamp to the supported range") + func fontPersistence() throws { + let suiteName = "ChatArtifactTextPreferencesTests.font.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let preferences = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + + #expect(preferences.fontSize(for: .code) == ChatArtifactTextPreferences.defaultFontSize) + #expect(preferences.setFontSize(40, for: .code) == 28) + #expect(preferences.setFontSize(6, for: .log) == 8) + + let reloaded = ChatArtifactTextPreferences(defaults: defaults, keyPrefix: "test") + #expect(reloaded.fontSize(for: .code) == 28) + #expect(reloaded.fontSize(for: .log) == 8) + #expect(reloaded.fontSize(for: .plainText) == ChatArtifactTextPreferences.defaultFontSize) + } + + @Test("paths resolve to stable preference kinds") + func kindResolution() { + #expect(ChatArtifactTextLayoutKind(path: "/tmp/build.log") == .log) + #expect(ChatArtifactTextLayoutKind(path: "/tmp/process.OUT") == .log) + #expect(ChatArtifactTextLayoutKind(path: "/tmp/main.swift") == .code) + #expect(ChatArtifactTextLayoutKind(path: "/tmp/README") == .plainText) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift new file mode 100644 index 000000000000..d7bab973243c --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerModelTests.swift @@ -0,0 +1,402 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite +struct ChatArtifactViewerModelTests { + @Test("large transport chunks are published in bounded UI batches") + @MainActor + func batchesLargeTransportChunks() async { + let line = "0123456789abcdef 漢🙂\n" + let text = String(repeating: line, count: 20_000) + let data = Data(text.utf8) + let loader = Self.loader(totalSize: Int64(data.count)) { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + let model = ChatArtifactViewerModel() + + await model.load(path: "/tmp/large.log", loader: loader) + + #expect(model.textChunks.count > 1) + #expect(model.textChunks.allSatisfy { $0.utf8.count <= 262_160 }) + #expect(model.renderedText == text) + #expect(model.textReachedEOF) + } + + @Test + @MainActor + func exposesFirstChunkBeforeEOFAndCompletesProgress() async throws { + let firstData = Data("first 漢".utf8) + let lastData = Data("🙂 last".utf8) + let totalSize = Int64(firstData.count + lastData.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk( + data: firstData, + offset: 0, + totalSize: totalSize, + eof: false + ), + ChatArtifactChunk( + data: lastData, + offset: Int64(firstData.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = Self.loader(totalSize: totalSize) { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + let model = ChatArtifactViewerModel() + + let loadTask = Task { + await model.load(path: "/tmp/progressive.txt", loader: loader) + } + await stream.waitUntilFirstChunkDelivered() + + #expect(model.state == .text) + #expect(model.renderedText == "first 漢") + #expect(!model.textReachedEOF) + #expect(model.fetchedBytes == Int64(firstData.count)) + #expect(model.totalBytes == totalSize) + + await stream.resume() + await loadTask.value + + #expect(model.renderedText == "first 漢🙂 last") + #expect(model.textReachedEOF) + #expect(model.fetchedBytes == totalSize) + #expect(model.totalBytes == totalSize) + } + + @Test + @MainActor + func pathChangeCancellationStopsThePreviousStream() async { + let firstPathData = Data("old".utf8) + let staleData = Data(" stale".utf8) + let staleTotalSize = Int64(firstPathData.count + staleData.count) + let blockedStream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk( + data: firstPathData, + offset: 0, + totalSize: staleTotalSize, + eof: false + ), + ChatArtifactChunk( + data: staleData, + offset: Int64(firstPathData.count), + totalSize: staleTotalSize, + eof: true + ), + ]) + let newData = Data("new path".utf8) + let loader = Self.loader(totalSize: Int64(newData.count)) { path, onChunk in + if path == "/tmp/old.txt" { + try await blockedStream.fetch(onChunk: onChunk) + return + } + try await onChunk( + ChatArtifactChunk( + data: newData, + offset: 0, + totalSize: Int64(newData.count), + eof: true + ) + ) + } + let model = ChatArtifactViewerModel() + + let oldTask = Task { + await model.load(path: "/tmp/old.txt", loader: loader) + } + await blockedStream.waitUntilFirstChunkDelivered() + oldTask.cancel() + let newTask = Task { + await model.load(path: "/tmp/new.txt", loader: loader) + } + + await blockedStream.waitUntilCancelled() + await oldTask.value + await newTask.value + + #expect(model.activePath == "/tmp/new.txt") + #expect(model.renderedText == "new path") + #expect(model.textReachedEOF) + } + + @Test + @MainActor + func tooLargeStateRetainsActualFileSize() async { + let limit = ChatArtifactTransferPolicy.defaultPolicy.maxPreviewBytes + let actualSize = limit + 42 + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: actualSize, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/plain" + ) + } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/tmp/too-large.txt", loader: loader) + + #expect(model.state == .tooLarge(actualSize: actualSize, limit: limit)) + } + + @Test + @MainActor + func pdfStreamsToTemporaryFileAndCleansUp() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-pdf-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let data = Data("%PDF-test".utf8) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(data.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "application/pdf" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + ) + let model = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: directory) + ) + + await model.load(path: "/remote/report", loader: loader) + + guard case .pdf(let fileURL) = model.state else { + Issue.record("PDF metadata should route to the PDF file state") + return + } + #expect(fileURL.pathExtension == "pdf") + #expect(try Data(contentsOf: fileURL) == data) + await model.cleanup() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test + @MainActor + func mediaUsesMediaCapAndRejectsFromStatBeforeFetch() async { + let limit = ChatArtifactTransferPolicy.defaultPolicy.maxMediaPreviewBytes + let streamCalls = ArtifactStreamCallCounter() + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: limit + 1, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "video/mp4" + ) + }, + stream: { _, _ in + await streamCalls.recordCall() + } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/remote/movie.mp4", loader: loader) + + #expect(model.state == .tooLarge(actualSize: limit + 1, limit: limit)) + #expect(await streamCalls.callCount() == 0) + } + + @Test + @MainActor + func mediaStreamsToExtensionPreservingFile() async throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-media-test-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let data = Data("media bytes".utf8) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(data.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "video/mp4" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + ) + let model = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: directory) + ) + + await model.load(path: "/remote/movie.mp4", loader: loader) + + guard case .media(let fileURL) = model.state else { + Issue.record("movie metadata should route to the media file state") + return + } + #expect(fileURL.pathExtension == "mp4") + #expect(try Data(contentsOf: fileURL) == data) + await model.cleanup() + #expect(!FileManager.default.fileExists(atPath: fileURL.path)) + } + + @Test + @MainActor + func quickLookRequiresCapabilityAcceptance() async throws { + let acceptedDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-ql-accepted-\(UUID().uuidString)", isDirectory: true) + let rejectedDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-artifact-ql-rejected-\(UUID().uuidString)", isDirectory: true) + defer { + try? FileManager.default.removeItem(at: acceptedDirectory) + try? FileManager.default.removeItem(at: rejectedDirectory) + } + let data = Data("document bytes".utf8) + let loader = Self.quickLookLoader(data: data) + let acceptedModel = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: acceptedDirectory) + ) + let rejectedModel = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: rejectedDirectory) + ) + + await acceptedModel.load( + path: "/remote/report.docx", + loader: loader, + quickLookCanPreview: { _ in true } + ) + await rejectedModel.load( + path: "/remote/report.docx", + loader: loader, + quickLookCanPreview: { _ in false } + ) + + guard case .quickLook(let acceptedURL) = acceptedModel.state else { + Issue.record("accepted document should route to Quick Look") + return + } + #expect(acceptedURL.pathExtension == "docx") + #expect(try Data(contentsOf: acceptedURL) == data) + guard case .binary = rejectedModel.state else { + Issue.record("rejected document should retain the binary state") + return + } + #expect(try FileManager.default.contentsOfDirectory(atPath: rejectedDirectory.path).isEmpty) + await acceptedModel.cleanup() + #expect(!FileManager.default.fileExists(atPath: acceptedURL.path)) + } + + @Test + @MainActor + func markdownStreamsAsTextAndAppliesRawOnlyThreshold() async { + let markdown = Data("# Heading\n\nBody".utf8) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: ChatArtifactMarkdownPresentation.maximumRenderedByteCount + 1, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/markdown" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: markdown, + offset: 0, + totalSize: Int64(markdown.count), + eof: true + )) + } + ) + let model = ChatArtifactViewerModel() + + await model.load(path: "/remote/README.md", loader: loader) + + #expect(model.state == .markdown) + #expect(model.renderedText == "# Heading\n\nBody") + #expect(model.markdownPresentation.mode == .raw) + #expect(!model.markdownPresentation.isRenderedAvailable) + } + + private static func loader( + totalSize: Int64, + stream: @escaping @Sendable ( + _ path: String, + _ onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Void + ) -> ChatArtifactLoader { + ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: totalSize, + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .text, + mimeType: "text/plain" + ) + }, + stream: stream + ) + } + + private static func quickLookLoader(data: Data) -> ChatArtifactLoader { + ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: Int64(data.count), + modifiedAt: Date(timeIntervalSince1970: 0), + kind: .binary, + mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + ) + }, + stream: { _, onChunk in + try await onChunk(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift new file mode 100644 index 000000000000..0f01943b8dde --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPagerModelTests.swift @@ -0,0 +1,203 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact viewer pager ownership") +struct ChatArtifactViewerPagerModelTests { + @Test("keeps page identity stable across gallery snapshot updates") + @MainActor + func keepsStablePageIdentity() throws { + let suiteName = "cmux.viewer-pager.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let initialOrder = swipeOrder([ + item(path: "/logs/build.log", size: 11_000_000), + item(path: "/logs/test.log", size: 2_000), + ]) + let model = ChatArtifactViewerPagerModel( + initialPath: "/logs/build.log", + swipeOrder: initialOrder, + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + let initialIdentity = try #require(model.pageIdentity(for: "/logs/build.log")) + + model.update(swipeOrder: swipeOrder([ + item(path: "/logs/new.log", size: 40), + item(path: "/logs/build.log", size: 11_500_000), + item(path: "/logs/test.log", size: 2_000), + ])) + + #expect(model.pageIdentity(for: "/logs/build.log") == initialIdentity) + #expect(model.pageSnapshots.map(\.path) == [ + "/logs/new.log", + "/logs/build.log", + "/logs/test.log", + ]) + } + + @Test("projects toolbar state from exactly the selected page") + @MainActor + func projectsOneToolbarState() throws { + let suiteName = "cmux.viewer-toolbar.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let model = ChatArtifactViewerPagerModel( + initialPath: "/first.txt", + swipeOrder: swipeOrder([ + item(path: "/first.txt", size: 10), + item(path: "/second.txt", size: 20), + item(path: "/third.txt", size: 30), + ]), + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + + #expect(model.toolbarSnapshot.path == "/first.txt") + model.select(path: "/second.txt") + #expect(model.toolbarSnapshot.path == "/second.txt") + #expect(model.pageSnapshots.count == 3) + } + + @Test("projects the highlighting pill only from the selected page") + @MainActor + func projectsSelectedPagePill() async throws { + let suiteName = "cmux.viewer-pill.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let logPath = "/logs/build.log" + let csvPath = "/data.csv" + let logSize = ChatArtifactSyntaxHighlightPolicy.maxHighlightBytes + 1 + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { path in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: path == logPath ? logSize : 1, + modifiedAt: Date(timeIntervalSince1970: 1), + kind: .text, + mimeType: "text/plain" + ) + }, + stream: { path, onChunk in + let totalSize = path == logPath ? logSize : 1 + try await onChunk(ChatArtifactChunk( + data: Data("x".utf8), + offset: 0, + totalSize: totalSize, + eof: true + )) + } + ) + let model = ChatArtifactViewerPagerModel( + initialPath: logPath, + swipeOrder: swipeOrder([ + item(path: logPath, size: logSize), + item(path: csvPath, size: 1), + ]), + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + + await model.actions( + for: logPath, + loader: loader, + quickLookCanPreview: { _ in false } + ).load() + await model.actions( + for: csvPath, + loader: loader, + quickLookCanPreview: { _ in false } + ).load() + + #expect(model.toolbarSnapshot.path == logPath) + #expect(model.toolbarSnapshot.showsHighlightingStatusPill) + model.select(path: csvPath) + #expect(model.toolbarSnapshot.path == csvPath) + #expect(!model.toolbarSnapshot.showsHighlightingStatusPill) + } + + @Test("streamed snapshots and gallery refreshes do not replay jump requests") + @MainActor + func keepsJumpRequestIdentity() async throws { + let suiteName = "cmux.viewer-jumps.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let path = "/build-\(UUID().uuidString).log" + let firstData = Data("first\n".utf8) + let secondData = Data("second\n".utf8) + let totalSize = Int64(firstData.count + secondData.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk(data: firstData, offset: 0, totalSize: totalSize, eof: false), + ChatArtifactChunk( + data: secondData, + offset: Int64(firstData.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: totalSize, + modifiedAt: Date(timeIntervalSince1970: 1), + kind: .text, + mimeType: "text/plain" + ) + }, + stream: { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + ) + let model = ChatArtifactViewerPagerModel( + initialPath: path, + swipeOrder: swipeOrder([item(path: path, size: totalSize)]), + textPreferences: ChatArtifactTextPreferences(defaults: defaults) + ) + let identity = try #require(model.pageIdentity(for: path)) + + model.requestTop() + let requestID = model.toolbarSnapshot.topRequestID + let actions = model.actions( + for: path, + loader: loader, + quickLookCanPreview: { _ in false } + ) + let loadTask = Task { await actions.load() } + await stream.waitUntilFirstChunkDelivered() + + #expect(model.pageIdentity(for: path) == identity) + #expect(model.toolbarSnapshot.textChunks == ["first\n"]) + #expect(model.toolbarSnapshot.topRequestID == requestID) + model.update(swipeOrder: swipeOrder([ + item(path: path, size: totalSize), + item(path: "/new.log", size: 100), + ])) + + #expect(requestID == 1) + #expect(model.pageIdentity(for: path) == identity) + #expect(model.toolbarSnapshot.topRequestID == requestID) + + await stream.resume() + await loadTask.value + #expect(model.toolbarSnapshot.textChunks == ["first\n", "second\n"]) + #expect(model.toolbarSnapshot.topRequestID == requestID) + } + + private func swipeOrder(_ items: [ChatArtifactGalleryItem]) -> ChatArtifactGallerySwipeOrder { + ChatArtifactGallerySwipeOrder(items: items) + } + + private func item(path: String, size: Int64) -> ChatArtifactGalleryItem { + ChatArtifactGalleryItem( + path: path, + kind: .text, + displayName: URL(fileURLWithPath: path).lastPathComponent, + size: size, + modifiedAt: Date(timeIntervalSince1970: Double(size)) + ) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift new file mode 100644 index 000000000000..5c15a0cfe62d --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactViewerPresentationRoutingTests.swift @@ -0,0 +1,86 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Artifact viewer present-first routing") +struct ChatArtifactViewerPresentationRoutingTests { + @Test( + "presents the destination before a blocking route finishes", + arguments: ["/remote/README.md", "/remote/report.pdf"] + ) + @MainActor + func presentsBeforeRouteFinishes(path: String) async throws { + let isMarkdown = path.hasSuffix(".md") + let firstData = Data((isMarkdown ? "# Heading\n" : "%PDF-first").utf8) + let lastData = Data((isMarkdown ? "\nBody" : "-last").utf8) + let totalSize = Int64(firstData.count + lastData.count) + let stream = ControlledArtifactStream(chunks: [ + ChatArtifactChunk( + data: firstData, + offset: 0, + totalSize: totalSize, + eof: false + ), + ChatArtifactChunk( + data: lastData, + offset: Int64(firstData.count), + totalSize: totalSize, + eof: true + ), + ]) + let loader = ChatArtifactLoader( + supportsArtifacts: true, + stat: { _ in + ChatArtifactStat( + exists: true, + isDirectory: false, + size: totalSize, + modifiedAt: Date(timeIntervalSince1970: 1), + kind: isMarkdown ? .text : .binary, + mimeType: isMarkdown ? "text/markdown" : "application/pdf" + ) + }, + stream: { _, onChunk in + try await stream.fetch(onChunk: onChunk) + } + ) + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-present-first-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let model = ChatArtifactViewerModel( + temporaryFileStore: ChatArtifactTemporaryFileStore(directory: directory) + ) + let presentation = ChatArtifactViewerPresentationCoordinator() + + presentation.present() + let loadTask = Task { + await presentation.loadAfterPresentation { + await model.load(path: path, loader: loader) + } + } + await stream.waitUntilFirstChunkDelivered() + + #expect(presentation.isPresented) + #expect(model.fetchedBytes == Int64(firstData.count)) + #expect(model.fetchedBytes < totalSize) + if isMarkdown { + #expect(model.state == .markdown) + } else { + #expect(model.state == .loading) + } + + await stream.resume() + _ = await loadTask.value + + if isMarkdown { + #expect(model.state == .markdown) + } else if case .pdf = model.state { + // The fully fetched PDF resolved in-page after presentation. + } else { + Issue.record("Expected the PDF route to resolve after presentation") + } + await model.cleanup() + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift new file mode 100644 index 000000000000..9c34d91c9841 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ChatArtifactZoomPolicyTests.swift @@ -0,0 +1,43 @@ +import Testing + +@testable import CmuxAgentChatUI + +@Suite("Chat artifact zoom policy") +struct ChatArtifactZoomPolicyTests { + @Test("fitted images double tap to three times and back") + func doubleTapToggle() { + let policy = ChatArtifactZoomPolicy() + + #expect(policy.minimumScale == 1) + #expect(policy.maximumScale == 8) + #expect(policy.scaleAfterDoubleTap(currentScale: 1) == 3) + #expect(policy.scaleAfterDoubleTap(currentScale: 3) == 1) + #expect(policy.scaleAfterDoubleTap(currentScale: 7) == 1) + } + + @Test("paging threshold tolerates UIScrollView scale noise") + func minimumThreshold() { + let policy = ChatArtifactZoomPolicy() + + #expect(policy.isAtMinimum(1)) + #expect(policy.isAtMinimum(1.005)) + #expect(!policy.isAtMinimum(1.02)) + } + + @Test("a horizontal image swipe at one-times zoom pages to the adjacent file") + func fittedImageSwipeBelongsToPager() throws { + let policy = ChatArtifactZoomPolicy() + var pager = ChatArtifactPageControllerState( + paths: ["/screenshots/shot-03.png", "/guide.md"], + selectedPath: "/screenshots/shot-03.png" + ) + + #expect(policy.horizontalSwipeOwner(at: 1) == .pager) + let adjacentPath = try #require(pager.path(after: pager.selectedPath)) + let didTransition = pager.completeTransition(to: adjacentPath) + #expect(didTransition) + #expect(pager.selectedPath == "/guide.md") + + #expect(policy.horizontalSwipeOwner(at: 3) == .image) + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ControlledArtifactStream.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ControlledArtifactStream.swift new file mode 100644 index 000000000000..f5abd935b0b1 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/ControlledArtifactStream.swift @@ -0,0 +1,78 @@ +import CmuxAgentChat +import Foundation + +/// Suspends after its first chunk so viewer tests can inspect and cancel mid-stream. +actor ControlledArtifactStream { + private let chunks: [ChatArtifactChunk] + private let resumeStream: AsyncStream + private let resumeContinuation: AsyncStream.Continuation + private var firstChunkDelivered = false + private var cancelled = false + private var firstChunkWaiters: [CheckedContinuation] = [] + private var cancellationWaiters: [CheckedContinuation] = [] + + init(chunks: [ChatArtifactChunk]) { + self.chunks = chunks + let pair = AsyncStream.makeStream() + resumeStream = pair.stream + resumeContinuation = pair.continuation + } + + func fetch( + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + defer { + if Task.isCancelled { + markCancelled() + } + } + + for (index, chunk) in chunks.enumerated() { + try await onChunk(chunk) + if index == chunks.startIndex { + markFirstChunkDelivered() + } + if index == chunks.startIndex, chunks.count > 1 { + var iterator = resumeStream.makeAsyncIterator() + _ = await iterator.next() + try Task.checkCancellation() + } + } + } + + func resume() { + resumeContinuation.yield() + } + + func waitUntilFirstChunkDelivered() async { + guard !firstChunkDelivered else { return } + await withCheckedContinuation { continuation in + firstChunkWaiters.append(continuation) + } + } + + func waitUntilCancelled() async { + guard !cancelled else { return } + await withCheckedContinuation { continuation in + cancellationWaiters.append(continuation) + } + } + + private func markFirstChunkDelivered() { + firstChunkDelivered = true + let waiters = firstChunkWaiters + firstChunkWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } + + private func markCancelled() { + cancelled = true + let waiters = cancellationWaiters + cancellationWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/CountingContentSource.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/CountingContentSource.swift new file mode 100644 index 000000000000..ea3e20c1a071 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/CountingContentSource.swift @@ -0,0 +1,30 @@ +import CmuxAgentChat +import Foundation + +/// Supplies deterministic artifact bytes while recording source fetches. +actor CountingContentSource { + private let values: [String: Data] + private var counts: [String: Int] = [:] + + init(values: [String: Data]) { + self.values = values + } + + func fetch( + key: String, + receive: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + let data = values[key] ?? Data() + counts[key, default: 0] += 1 + try await receive(ChatArtifactChunk( + data: data, + offset: 0, + totalSize: Int64(data.count), + eof: true + )) + } + + func fetchCount(for key: String) -> Int { + counts[key, default: 0] + } +} diff --git a/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/UTF8ChunkAssemblerTests.swift b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/UTF8ChunkAssemblerTests.swift new file mode 100644 index 000000000000..37fcd588e679 --- /dev/null +++ b/Packages/iOS/CmuxAgentChatUI/Tests/CmuxAgentChatUITests/UTF8ChunkAssemblerTests.swift @@ -0,0 +1,46 @@ +import Foundation +import Testing + +@testable import CmuxAgentChatUI + +@Suite +struct UTF8ChunkAssemblerTests { + @Test + func preservesCJKAndEmojiAcrossEveryByteBoundary() throws { + let expected = "start 漢字🙂終わり end" + let data = Data(expected.utf8) + + for boundary in 1.. Void)?, + fetchChunk: @Sendable (_ offset: Int64) async throws -> ChatArtifactChunk, + onChunk: @Sendable (_ chunk: ChatArtifactChunk) async throws -> Void + ) async throws -> Data { + var offset: Int64 = 0 + var result = Data() + while true { + try Task.checkCancellation() + let chunk = try await fetchChunk(offset) + try Task.checkCancellation() + + if collectsData { + if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { + result.reserveCapacity(Int(chunk.totalSize)) + } + result.append(chunk.data) + } + offset = chunk.offset + Int64(chunk.data.count) + progress?(offset, chunk.totalSize) + try await onChunk(chunk) + if chunk.eof { + return result + } + guard !chunk.data.isEmpty else { + throw ChatArtifactError.macUnreachable + } + } + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift new file mode 100644 index 000000000000..3b2fdeb07b4c --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource+TerminalArtifacts.swift @@ -0,0 +1,137 @@ +public import CmuxAgentChat +public import Foundation + +/// Terminal-scoped artifact RPCs, extracted from `MobileChatEventSource.swift`, +/// which sits at its file-length budget. +extension MobileChatEventSource { + /// Scans file references rendered by one terminal surface. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the terminal. + /// - surfaceID: Terminal surface to scan. + /// - visibleOnly: Whether to scan only the rendered viewport. The default + /// keeps the existing visible-screen-plus-scrollback behavior. + /// - countOnly: Whether to skip terminal items and return only the bound + /// session's complete gallery count when supported. + /// - Returns: Capped file references detected by the Mac. + public func terminalArtifactScan( + workspaceID: String, + surfaceID: String, + visibleOnly: Bool = false, + countOnly: Bool = false + ) async throws -> TerminalArtifactScanResponse { + var params: [String: Any] = [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + ] + if visibleOnly { + params["visible_only"] = true + } + if countOnly { + params["count_only"] = true + } + if supportsTerminalArtifactList { + params["include_directories"] = true + } + return try await artifactCall( + method: "mobile.terminal.artifact.scan", + params: params + ) + } + + public func terminalArtifactStat( + workspaceID: String, + surfaceID: String, + path: String + ) async throws -> ChatArtifactStat { + try await artifactCall( + method: "mobile.terminal.artifact.stat", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ] + ) + } + + public func terminalArtifactFetch( + workspaceID: String, + surfaceID: String, + path: String, + progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? + ) async throws -> Data { + try await fetchArtifactChunks( + method: "mobile.terminal.artifact.fetch", + stringParams: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ], + collectsData: true, + progress: progress, + onChunk: { _ in } + ) + } + + /// Streams terminal-scoped artifact chunks without accumulating a second copy. + /// + /// - Parameters: + /// - workspaceID: Workspace containing the terminal surface. + /// - surfaceID: Terminal surface whose visible paths authorize the fetch. + /// - path: Absolute Mac host path. + /// - onChunk: Structured callback for each fetched chunk. + public func terminalArtifactFetch( + workspaceID: String, + surfaceID: String, + path: String, + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + _ = try await fetchArtifactChunks( + method: "mobile.terminal.artifact.fetch", + stringParams: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ], + collectsData: false, + progress: nil, + onChunk: onChunk + ) + } + + public func terminalArtifactThumbnail( + workspaceID: String, + surfaceID: String, + path: String, + maxDimension: Int + ) async throws -> ChatArtifactThumbnail { + try await artifactCall( + method: "mobile.terminal.artifact.thumbnail", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + "max_dimension": maxDimension, + ] + ) + } + + /// Lists immediate entries in a terminal-visible artifact directory. + public func terminalArtifactList( + workspaceID: String, + surfaceID: String, + path: String + ) async throws -> ChatArtifactDirectoryListing { + guard supportsTerminalArtifactList else { + throw ChatArtifactError.unsupported + } + return try await artifactCall( + method: "mobile.terminal.artifact.list", + params: [ + "workspace_id": workspaceID, + "surface_id": surfaceID, + "path": path, + ] + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift index a9dc11990456..eab3cbc862ce 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileChatEventSource.swift @@ -14,6 +14,10 @@ public actor MobileChatEventSource: ChatEventSource { private let client: MobileCoreRPCClient private let coding = ChatWireCoding() public nonisolated let supportsArtifacts: Bool + /// Whether the connected Mac supports recursive chat folder browsing. + public nonisolated let supportsArtifactFolders: Bool + /// Whether the connected Mac supports terminal-scoped directory listing. + public nonisolated let supportsTerminalArtifactList: Bool /// Whether the connected Mac supports session-wide artifact gallery pages. public nonisolated let supportsArtifactGallery: Bool @@ -23,11 +27,15 @@ public actor MobileChatEventSource: ChatEventSource { public init( client: MobileCoreRPCClient, supportsArtifacts: Bool = false, - supportsArtifactGallery: Bool = false + supportsArtifactGallery: Bool = false, + supportsArtifactFolders: Bool = false, + supportsTerminalArtifactList: Bool = false ) { self.client = client self.supportsArtifacts = supportsArtifacts self.supportsArtifactGallery = supportsArtifactGallery + self.supportsArtifactFolders = supportsArtifactFolders + self.supportsTerminalArtifactList = supportsTerminalArtifactList } /// Lists chat-capable agent sessions the Mac knows about. @@ -254,31 +262,27 @@ public actor MobileChatEventSource: ChatEventSource { path: String, progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? ) async throws -> Data { - var offset: Int64 = 0 - var result = Data() - while true { - let chunk: ChatArtifactChunk = try await artifactCall( - method: "mobile.chat.artifact.fetch", - params: [ - "session_id": sessionID, - "path": path, - "offset": offset, - "length": ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes, - ] - ) - if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { - result.reserveCapacity(Int(chunk.totalSize)) - } - result.append(chunk.data) - offset = chunk.offset + Int64(chunk.data.count) - progress?(offset, chunk.totalSize) - if chunk.eof { - return result - } - guard !chunk.data.isEmpty else { - throw ChatArtifactError.macUnreachable - } - } + try await fetchArtifactChunks( + method: "mobile.chat.artifact.fetch", + stringParams: ["session_id": sessionID, "path": path], + collectsData: true, + progress: progress, + onChunk: { _ in } + ) + } + + public func artifactFetch( + sessionID: String, + path: String, + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws { + _ = try await fetchArtifactChunks( + method: "mobile.chat.artifact.fetch", + stringParams: ["session_id": sessionID, "path": path], + collectsData: false, + progress: nil, + onChunk: onChunk + ) } public func artifactThumbnail( @@ -297,7 +301,10 @@ public actor MobileChatEventSource: ChatEventSource { } public func artifactList(sessionID: String, path: String) async throws -> ChatArtifactDirectoryListing { - try await artifactCall( + guard supportsArtifactFolders else { + throw ChatArtifactError.unsupported + } + return try await artifactCall( method: "mobile.chat.artifact.list", params: [ "session_id": sessionID, @@ -330,111 +337,17 @@ public actor MobileChatEventSource: ChatEventSource { if let query, !query.isEmpty { params["query"] = query } - return try await artifactCall( - method: "mobile.chat.artifact.gallery", - params: params - ) - } - - /// Scans file references rendered by one terminal surface. - /// - /// - Parameters: - /// - workspaceID: Workspace containing the terminal. - /// - surfaceID: Terminal surface to scan. - /// - visibleOnly: Whether to scan only the rendered viewport. The default - /// keeps the existing visible-screen-plus-scrollback behavior. - /// - countOnly: Whether to skip terminal items and return only the bound - /// session's complete gallery count when supported. - /// - Returns: Capped file references detected by the Mac. - public func terminalArtifactScan( - workspaceID: String, - surfaceID: String, - visibleOnly: Bool = false, - countOnly: Bool = false - ) async throws -> TerminalArtifactScanResponse { - var params: [String: Any] = [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - ] - if visibleOnly { - params["visible_only"] = true - } - if countOnly { - params["count_only"] = true + if supportsArtifactFolders { + params["include_directories"] = true } - return try await artifactCall( - method: "mobile.terminal.artifact.scan", + let page: ChatArtifactGalleryPage = try await artifactCall( + method: "mobile.chat.artifact.gallery", params: params ) + return supportsArtifactFolders ? page : page.excludingDirectories() } - public func terminalArtifactStat( - workspaceID: String, - surfaceID: String, - path: String - ) async throws -> ChatArtifactStat { - try await artifactCall( - method: "mobile.terminal.artifact.stat", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - ] - ) - } - - public func terminalArtifactFetch( - workspaceID: String, - surfaceID: String, - path: String, - progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)? - ) async throws -> Data { - var offset: Int64 = 0 - var result = Data() - while true { - let chunk: ChatArtifactChunk = try await artifactCall( - method: "mobile.terminal.artifact.fetch", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - "offset": offset, - "length": ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes, - ] - ) - if result.isEmpty, chunk.totalSize > 0, chunk.totalSize <= Int64(Int.max) { - result.reserveCapacity(Int(chunk.totalSize)) - } - result.append(chunk.data) - offset = chunk.offset + Int64(chunk.data.count) - progress?(offset, chunk.totalSize) - if chunk.eof { - return result - } - guard !chunk.data.isEmpty else { - throw ChatArtifactError.macUnreachable - } - } - } - - public func terminalArtifactThumbnail( - workspaceID: String, - surfaceID: String, - path: String, - maxDimension: Int - ) async throws -> ChatArtifactThumbnail { - try await artifactCall( - method: "mobile.terminal.artifact.thumbnail", - params: [ - "workspace_id": workspaceID, - "surface_id": surfaceID, - "path": path, - "max_dimension": maxDimension, - ] - ) - } - - private func artifactCall( + func artifactCall( method: String, params: [String: Any] ) async throws -> T { @@ -447,6 +360,27 @@ public actor MobileChatEventSource: ChatEventSource { } } + func fetchArtifactChunks( + method: String, + stringParams: [String: String], + collectsData: Bool, + progress: (@Sendable (_ fetchedBytes: Int64, _ totalBytes: Int64) -> Void)?, + onChunk: @Sendable (ChatArtifactChunk) async throws -> Void + ) async throws -> Data { + let loop = MobileArtifactChunkFetchLoop() + return try await loop.run( + collectsData: collectsData, + progress: progress + ) { offset in + var params: [String: Any] = stringParams + params["offset"] = offset + params["length"] = ChatArtifactTransferPolicy.defaultPolicy.maxRawChunkBytes + return try await self.artifactCall(method: method, params: params) + } onChunk: { chunk in + try await onChunk(chunk) + } + } + private nonisolated static func artifactError(from error: any Error) -> ChatArtifactError { guard let connectionError = error as? MobileShellConnectionError else { return .macUnreachable diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift index cc88d4bd2944..b2f349e5d06c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AgentChat.swift @@ -29,7 +29,9 @@ extension MobileShellComposite { return MobileChatEventSource( client: client, supportsArtifacts: supportsChatArtifacts, - supportsArtifactGallery: supportsChatArtifactGallery + supportsArtifactGallery: supportsChatArtifactGallery, + supportsArtifactFolders: supportsChatArtifactFolders, + supportsTerminalArtifactList: supportsTerminalArtifactList ) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift index 085d49319d25..2236aa9d4edd 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+Capabilities.swift @@ -1,4 +1,7 @@ extension MobileShellComposite { + static let chatArtifactFoldersCapability = "chat.artifact.folders.v1" + static let terminalArtifactListCapability = "terminal.artifact.list.v1" + /// Whether the Mac supports workspace close requests. public var supportsWorkspaceCloseActions: Bool { supportedHostCapabilities.contains(Self.workspaceCloseCapability) } /// Whether the Mac supports workspace move/reorder requests. @@ -17,6 +20,14 @@ extension MobileShellComposite { public var supportsChatArtifactGallery: Bool { supportedHostCapabilities.contains(Self.chatArtifactGalleryCapability) } + /// Whether the Mac supports recursive chat artifact folder browsing. + public var supportsChatArtifactFolders: Bool { + supportedHostCapabilities.contains(Self.chatArtifactFoldersCapability) + } /// Whether the Mac supports terminal artifact scan/stat/fetch/thumbnail RPCs. public var supportsTerminalArtifacts: Bool { supportedHostCapabilities.contains(Self.terminalArtifactCapability) } + /// Whether the Mac supports terminal-scoped directory listing. + public var supportsTerminalArtifactList: Bool { + supportedHostCapabilities.contains(Self.terminalArtifactListCapability) + } } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift new file mode 100644 index 000000000000..d78b2c06ccd0 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkFetchLoopTests.swift @@ -0,0 +1,93 @@ +import CmuxAgentChat +import Foundation +import Testing + +@testable import CmuxMobileShell + +@Suite +struct MobileArtifactChunkFetchLoopTests { + @Test + func sequencesOffsetsAndStopsAtEOF() async throws { + let first = ChatArtifactChunk( + data: Data("abc".utf8), + offset: 0, + totalSize: 5, + eof: false + ) + let last = ChatArtifactChunk( + data: Data("de".utf8), + offset: 3, + totalSize: 5, + eof: true + ) + let script = MobileArtifactChunkScript(chunks: [first, last]) + + _ = try await MobileArtifactChunkFetchLoop().run( + collectsData: false, + progress: nil + ) { offset in + try await script.fetch(offset: offset) + } onChunk: { chunk in + await script.record(chunk) + } + + let snapshot = await script.snapshot() + #expect(snapshot.requestedOffsets == [0, 3]) + #expect(snapshot.deliveredChunks == [first, last]) + } + + @Test + func acceptsEmptyEOFChunk() async throws { + let emptyEOF = ChatArtifactChunk( + data: Data(), + offset: 0, + totalSize: 0, + eof: true + ) + let script = MobileArtifactChunkScript(chunks: [emptyEOF]) + + _ = try await MobileArtifactChunkFetchLoop().run( + collectsData: false, + progress: nil + ) { offset in + try await script.fetch(offset: offset) + } onChunk: { chunk in + await script.record(chunk) + } + + let snapshot = await script.snapshot() + #expect(snapshot.requestedOffsets == [0]) + #expect(snapshot.deliveredChunks == [emptyEOF]) + } + + @Test + func rejectsEmptyNonEOFChunkAsMacUnreachable() async { + let stalled = ChatArtifactChunk( + data: Data(), + offset: 0, + totalSize: 8, + eof: false + ) + let script = MobileArtifactChunkScript(chunks: [stalled]) + + do { + _ = try await MobileArtifactChunkFetchLoop().run( + collectsData: false, + progress: nil + ) { offset in + try await script.fetch(offset: offset) + } onChunk: { chunk in + await script.record(chunk) + } + Issue.record("empty non-EOF chunk should fail") + } catch let error as ChatArtifactError { + #expect(error == .macUnreachable) + } catch { + Issue.record("unexpected error: \(error)") + } + + let snapshot = await script.snapshot() + #expect(snapshot.requestedOffsets == [0]) + #expect(snapshot.deliveredChunks == [stalled]) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift new file mode 100644 index 000000000000..81eb303cd62f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileArtifactChunkScript.swift @@ -0,0 +1,29 @@ +import CmuxAgentChat +import Foundation + +/// Deterministic chunk source and delivery recorder for fetch-loop tests. +actor MobileArtifactChunkScript { + private let chunksByOffset: [Int64: ChatArtifactChunk] + private var requestedOffsets: [Int64] = [] + private var deliveredChunks: [ChatArtifactChunk] = [] + + init(chunks: [ChatArtifactChunk]) { + chunksByOffset = Dictionary(uniqueKeysWithValues: chunks.map { ($0.offset, $0) }) + } + + func fetch(offset: Int64) throws -> ChatArtifactChunk { + requestedOffsets.append(offset) + guard let chunk = chunksByOffset[offset] else { + throw ChatArtifactError.fileNotFound + } + return chunk + } + + func record(_ chunk: ChatArtifactChunk) { + deliveredChunks.append(chunk) + } + + func snapshot() -> (requestedOffsets: [Int64], deliveredChunks: [ChatArtifactChunk]) { + (requestedOffsets, deliveredChunks) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift index a3ca5d8c3c45..125327bc523f 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellWorkspaceCapabilityTests.swift @@ -6,6 +6,23 @@ import Testing @MainActor @Suite struct MobileShellWorkspaceCapabilityTests { + @Test func artifactFolderCapabilitiesFailClosedForOlderHosts() { + let store = MobileShellComposite.preview() + store.supportedHostCapabilities = [ + "chat.artifact.v1", + "terminal.artifact.v1", + ] + #expect(!store.supportsChatArtifactFolders) + #expect(!store.supportsTerminalArtifactList) + + store.supportedHostCapabilities.formUnion([ + "chat.artifact.folders.v1", + "terminal.artifact.list.v1", + ]) + #expect(store.supportsChatArtifactFolders) + #expect(store.supportsTerminalArtifactList) + } + @Test func workspaceMutationCapabilitiesAreVersionAndTicketGated() async throws { let oldMac = try await connectedStore(capabilities: [ "events.v1", diff --git a/Packages/iOS/CmuxMobileShellUI/Package.resolved b/Packages/iOS/CmuxMobileShellUI/Package.resolved index f7cc217c9778..8dfa8c37170b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Package.resolved +++ b/Packages/iOS/CmuxMobileShellUI/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "680f016588d1919fc59e09829d71907c229c473ea64dcd38f9013a99e7c9560e", + "originHash" : "8e4d600dd748d7cf440352cd9cd6219c40b399f51b2f821c0c4ddf4fae27d332", "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift index 02ff06453e89..3a410556b843 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceCoordinator+Artifacts.swift @@ -12,11 +12,18 @@ extension GhosttySurfaceRepresentable.Coordinator { @discardableResult func updateArtifactCountMode( artifactFilesEnabled: Bool, + terminalFilesChipEnabled: Bool, sessionArtifactCountEnabled: Bool ) -> Bool { + let artifactChipGate = TerminalArtifactChipFeatureGate( + artifactsAvailable: artifactFilesEnabled, + preferenceEnabled: terminalFilesChipEnabled + ) let changed = self.artifactFilesEnabled != artifactFilesEnabled + || self.artifactChipGate != artifactChipGate || self.sessionArtifactCountEnabled != sessionArtifactCountEnabled self.artifactFilesEnabled = artifactFilesEnabled + self.artifactChipGate = artifactChipGate self.sessionArtifactCountEnabled = sessionArtifactCountEnabled guard changed else { return false } @@ -24,8 +31,9 @@ extension GhosttySurfaceRepresentable.Coordinator { artifactCountTask = nil artifactCountTaskRequest = nil artifactCountState.reset() - artifactCountNeedsRefresh = artifactFilesEnabled + artifactCountNeedsRefresh = artifactChipGate.isEnabled visibleArtifactCount = 0 + freshestLocalArtifactCount = 0 return true } @@ -37,10 +45,14 @@ extension GhosttySurfaceRepresentable.Coordinator { case .none: break case .report(let report): - surfaceView.reportArtifactCount( + guard surfaceView.reportArtifactCount( report.count, generation: report.surfaceGeneration - ) + ) else { return } + onArtifactGalleryRefreshSignal(TerminalArtifactGalleryRefreshSignal( + count: report.count, + surfaceGeneration: report.surfaceGeneration + )) case .request(let request): startArtifactCountRequest(request, surfaceView: surfaceView) } @@ -52,21 +64,21 @@ extension GhosttySurfaceRepresentable.Coordinator { ) { let workspaceID = workspaceID let surfaceID = surfaceID + let artifactChipGate = artifactChipGate artifactCountTaskRequest = request artifactCountTask = Task { @MainActor [weak self, weak surfaceView] in let sessionTotal: Int? - if let source = self?.store?.makeChatEventSource() { - do { + do { + sessionTotal = try await artifactChipGate.performScan { [weak self] in + guard let source = self?.store?.makeChatEventSource() else { return nil } let response = try await source.terminalArtifactScan( workspaceID: workspaceID, surfaceID: surfaceID, countOnly: true ) - sessionTotal = response.sessionArtifactTotal - } catch { - sessionTotal = nil + return response.sessionArtifactTotal } - } else { + } catch { sessionTotal = nil } @@ -74,16 +86,22 @@ extension GhosttySurfaceRepresentable.Coordinator { let completion = self.artifactCountState.complete( request, sessionTotal: sessionTotal, - currentSurfaceGeneration: surfaceView.visibleArtifactCountGeneration + currentSurfaceGeneration: surfaceView.visibleArtifactCountGeneration, + freshestLocalCount: self.freshestLocalArtifactCount ) guard self.artifactCountTaskRequest == request else { return } self.artifactCountTask = nil self.artifactCountTaskRequest = nil if case .reported(let report) = completion.outcome { - surfaceView.reportArtifactCount( + if surfaceView.reportArtifactCount( report.count, generation: report.surfaceGeneration - ) + ) { + self.onArtifactGalleryRefreshSignal(TerminalArtifactGalleryRefreshSignal( + count: report.count, + surfaceGeneration: report.surfaceGeneration + )) + } } if let nextRequest = completion.nextRequest { self.startArtifactCountRequest(nextRequest, surfaceView: surfaceView) @@ -94,9 +112,10 @@ extension GhosttySurfaceRepresentable.Coordinator { /// Projects the workspace's value count into a small SwiftUI chip hosted /// by the terminal surface, preserving the dock's keyboard geometry. @MainActor - func updateArtifactChip(count: Int, enabled: Bool) { + func updateArtifactChip(count: Int) { visibleArtifactCount = count guard let surfaceView else { return } + let enabled = artifactChipGate.isEnabled let renderState = (count: count, enabled: enabled) if let lastArtifactChipRender, lastArtifactChipRender == renderState { return @@ -126,6 +145,7 @@ extension GhosttySurfaceRepresentable.Coordinator { @MainActor private func requestArtifactFilesFromChip() { + guard artifactChipGate.isEnabled else { return } guard let surfaceView, let chipView = artifactChipController?.view else { return } let frame = chipView.convert(chipView.bounds, to: surfaceView) let width = max(surfaceView.bounds.width, 1) @@ -182,7 +202,8 @@ extension GhosttySurfaceRepresentable.Coordinator { didDetectVisibleArtifactCount count: Int, generation: UInt64 ) { - guard artifactFilesEnabled else { return } + guard artifactChipGate.isEnabled else { return } + freshestLocalArtifactCount = count let action = artifactCountState.trigger( localCount: count, surfaceGeneration: generation, @@ -196,13 +217,18 @@ extension GhosttySurfaceRepresentable.Coordinator { artifactCountTask = nil artifactCountTaskRequest = nil artifactCountState.reset() - artifactCountNeedsRefresh = artifactFilesEnabled + artifactCountNeedsRefresh = artifactChipGate.isEnabled + freshestLocalArtifactCount = 0 let previousCount = visibleArtifactCount visibleArtifactCount = 0 guard self.surfaceView === surfaceView else { return } - updateArtifactChip(count: 0, enabled: artifactFilesEnabled) + updateArtifactChip(count: 0) guard previousCount != 0 else { return } onVisibleArtifactCountChanged(0) + onArtifactGalleryRefreshSignal(TerminalArtifactGalleryRefreshSignal( + count: 0, + surfaceGeneration: surfaceView.visibleArtifactCountGeneration + )) } func ghosttySurfaceView( @@ -210,7 +236,7 @@ extension GhosttySurfaceRepresentable.Coordinator { didChangeVisibleArtifactCount count: Int ) { artifactCountNeedsRefresh = false - guard artifactFilesEnabled, count != visibleArtifactCount else { return } + guard artifactChipGate.isEnabled, count != visibleArtifactCount else { return } visibleArtifactCount = count onVisibleArtifactCountChanged(count) } @@ -224,25 +250,27 @@ extension GhosttySurfaceRepresentable.Coordinator { } } - func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) { + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didTapAtCol col: Int, + row: Int + ) async -> GhosttySurfaceTapDisposition { // Forward to the Mac's real surface as a left click; libghostty // reports it to a TUI with mouse mode, or no-ops on a normal screen. - Task { @MainActor [weak self] in - guard let self else { return } - if self.artifactFilesEnabled, - let snapshot = await surfaceView.visibleTextForArtifactHitTesting() { - if let path = TerminalArtifactTapHitTester().path( - in: snapshot.text, - col: col, - row: row, - columns: snapshot.columns - ) { - self.onArtifactPathTapped(path) - return - } + if artifactFilesEnabled, + let snapshot = await surfaceView.visibleTextForArtifactHitTesting() { + if let path = TerminalArtifactTapHitTester().path( + in: snapshot.text, + col: col, + row: row, + columns: snapshot.columns + ) { + onArtifactPathTapped(path) + return .openedArtifact } - await self.store?.clickTerminal(surfaceID: self.surfaceID, col: col, row: row) } + await store?.clickTerminal(surfaceID: surfaceID, col: col, row: row) + return .focusTerminal } func ghosttySurfaceView( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 0ab66f25eb23..b8b65535a10c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -46,11 +46,13 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { /// Ghostty config update without remounting or changing another scene. var configThemeGeneration: UInt64 = 0 var artifactFilesEnabled: Bool = false + var terminalFilesChipEnabled: Bool = false var sessionArtifactCountEnabled: Bool = false var visibleArtifactCount: Int = 0 var onArtifactFilesRequested: @MainActor (_ anchor: UnitPoint) -> Void = { _ in } var onArtifactPathTapped: @MainActor (_ path: String) -> Void = { _ in } var onVisibleArtifactCountChanged: @MainActor (_ count: Int) -> Void = { _ in } + var onArtifactGalleryRefreshSignal: @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void = { _ in } func makeCoordinator() -> Coordinator { Coordinator( @@ -58,11 +60,13 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { surfaceID: surfaceID, store: store, artifactFilesEnabled: artifactFilesEnabled, + terminalFilesChipEnabled: terminalFilesChipEnabled, sessionArtifactCountEnabled: sessionArtifactCountEnabled, visibleArtifactCount: visibleArtifactCount, onArtifactFilesRequested: onArtifactFilesRequested, onArtifactPathTapped: onArtifactPathTapped, - onVisibleArtifactCountChanged: onVisibleArtifactCountChanged + onVisibleArtifactCountChanged: onVisibleArtifactCountChanged, + onArtifactGalleryRefreshSignal: onArtifactGalleryRefreshSignal ) } @@ -126,8 +130,10 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { context.coordinator.onArtifactFilesRequested = onArtifactFilesRequested context.coordinator.onArtifactPathTapped = onArtifactPathTapped context.coordinator.onVisibleArtifactCountChanged = onVisibleArtifactCountChanged + context.coordinator.onArtifactGalleryRefreshSignal = onArtifactGalleryRefreshSignal let artifactCountModeChanged = context.coordinator.updateArtifactCountMode( artifactFilesEnabled: artifactFilesEnabled, + terminalFilesChipEnabled: terminalFilesChipEnabled, sessionArtifactCountEnabled: sessionArtifactCountEnabled ) surfaceView.artifactFilesEnabled = artifactFilesEnabled @@ -137,10 +143,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { let projectedArtifactCount = context.coordinator.artifactCountNeedsRefresh ? 0 : visibleArtifactCount - context.coordinator.updateArtifactChip( - count: projectedArtifactCount, - enabled: artifactFilesEnabled - ) + context.coordinator.updateArtifactChip(count: projectedArtifactCount) surfaceView.setComposerActive(isComposerActive) context.coordinator.setComposerMounted(isComposerActive) context.coordinator.scheduleTheme(terminalConfigTheme, generation: configThemeGeneration) @@ -163,11 +166,13 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { weak var store: CMUXMobileShellStore? weak var surfaceView: GhosttySurfaceView? var artifactFilesEnabled: Bool + var artifactChipGate: TerminalArtifactChipFeatureGate var sessionArtifactCountEnabled: Bool var visibleArtifactCount: Int var onArtifactFilesRequested: @MainActor (_ anchor: UnitPoint) -> Void var onArtifactPathTapped: @MainActor (_ path: String) -> Void var onVisibleArtifactCountChanged: @MainActor (_ count: Int) -> Void + var onArtifactGalleryRefreshSignal: @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void private var outputTask: Task? private var liveFontTask: Task? let themeApplicationScheduler = TerminalThemeApplicationScheduler() @@ -175,6 +180,7 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { var artifactCountTaskRequest: TerminalArtifactChipCountState.Request? var artifactCountState = TerminalArtifactChipCountState() var artifactCountNeedsRefresh: Bool + var freshestLocalArtifactCount = 0 /// Hosts the SwiftUI ``TerminalComposerView`` so it can be installed into the /// surface's composer band. Built lazily on first open and torn down on /// dismantle; mounted/unmounted by ``setComposerMounted(_:)``. @@ -202,32 +208,36 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { surfaceID: String, store: CMUXMobileShellStore, artifactFilesEnabled: Bool, + terminalFilesChipEnabled: Bool, sessionArtifactCountEnabled: Bool, visibleArtifactCount: Int, onArtifactFilesRequested: @escaping @MainActor (_ anchor: UnitPoint) -> Void, onArtifactPathTapped: @escaping @MainActor (_ path: String) -> Void, - onVisibleArtifactCountChanged: @escaping @MainActor (_ count: Int) -> Void + onVisibleArtifactCountChanged: @escaping @MainActor (_ count: Int) -> Void, + onArtifactGalleryRefreshSignal: @escaping @MainActor (TerminalArtifactGalleryRefreshSignal) -> Void ) { self.workspaceID = workspaceID self.surfaceID = surfaceID self.store = store self.artifactFilesEnabled = artifactFilesEnabled + self.artifactChipGate = TerminalArtifactChipFeatureGate( + artifactsAvailable: artifactFilesEnabled, + preferenceEnabled: terminalFilesChipEnabled + ) self.sessionArtifactCountEnabled = sessionArtifactCountEnabled self.visibleArtifactCount = visibleArtifactCount - self.artifactCountNeedsRefresh = artifactFilesEnabled + self.artifactCountNeedsRefresh = artifactChipGate.isEnabled self.onArtifactFilesRequested = onArtifactFilesRequested self.onArtifactPathTapped = onArtifactPathTapped self.onVisibleArtifactCountChanged = onVisibleArtifactCountChanged + self.onArtifactGalleryRefreshSignal = onArtifactGalleryRefreshSignal super.init() } func attach(surfaceView: GhosttySurfaceView) { self.surfaceView = surfaceView surfaceView.artifactFilesEnabled = artifactFilesEnabled - updateArtifactChip( - count: artifactCountNeedsRefresh ? 0 : visibleArtifactCount, - enabled: artifactFilesEnabled - ) + updateArtifactChip(count: artifactCountNeedsRefresh ? 0 : visibleArtifactCount) guard let store else { return } let surfaceID = surfaceID viewportReportScheduler = TerminalViewportReportScheduler( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index 0a6063d43bcd..ff63145fd2c6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -22,6 +22,8 @@ public final class MobileDisplaySettings { private nonisolated(unsafe) let defaults: UserDefaults private static let wrapWorkspaceTitlesKey = "cmux.mobile.wrapWorkspaceTitles" private static let showAltScreenNoticeKey = "cmux.mobile.showAltScreenNotice" + private static let showMissingFilesKey = "cmux.mobile.showMissingFiles" + private static let terminalFilesChipEnabledKey = "cmux.mobile.terminalFilesChipEnabled" private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" @@ -58,6 +60,22 @@ public final class MobileDisplaySettings { didSet { defaults.set(showAltScreenNotice, forKey: Self.showAltScreenNoticeKey) } } + /// Whether artifact galleries include paths that no longer exist on the + /// connected Mac. Defaults to `false`. Mutating this writes through to the + /// injected ``UserDefaults``. + public var showMissingFiles: Bool { + didSet { defaults.set(showMissingFiles, forKey: Self.showMissingFilesKey) } + } + + /// Whether the beta terminal files chip and its count scan are enabled. + /// Defaults to `false`. Mutating this writes through to the injected + /// ``UserDefaults``. + public var terminalFilesChipEnabled: Bool { + didSet { + defaults.set(terminalFilesChipEnabled, forKey: Self.terminalFilesChipEnabledKey) + } + } + /// How many lines a workspace row's activity preview shows (1 or 2). /// Defaults to 2. Mutating this clamps to the supported range and writes /// through to the injected ``UserDefaults``. @@ -103,11 +121,14 @@ public final class MobileDisplaySettings { /// - Parameter defaults: The store backing the persisted preferences. /// Defaults to `.standard`; tests pass a scoped suite. Stored properties /// are initialized from `defaults`; absent keys read as their default - /// (single-line titles, two preview lines) without a write. + /// (single-line titles, hidden missing files, two preview lines) without + /// a write. public init(defaults: UserDefaults = .standard) { self.defaults = defaults self.wrapWorkspaceTitles = defaults.bool(forKey: Self.wrapWorkspaceTitlesKey) self.showAltScreenNotice = defaults.object(forKey: Self.showAltScreenNoticeKey) as? Bool ?? true + self.showMissingFiles = defaults.bool(forKey: Self.showMissingFilesKey) + self.terminalFilesChipEnabled = defaults.bool(forKey: Self.terminalFilesChipEnabledKey) let storedPreviewLines = defaults.object(forKey: Self.workspacePreviewLineCountKey) as? Int self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( storedPreviewLines ?? Self.defaultWorkspacePreviewLineCount diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 4e879737fe19..75233db0ef96 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -167,6 +167,16 @@ struct MobileSettingsView: View { .accessibilityIdentifier("MobileSettingsTerminalShortcuts") } + Section(L10n.string("mobile.settings.betaFeatures", defaultValue: "Beta Features")) { + Toggle(isOn: $displaySettings.terminalFilesChipEnabled) { + Text(L10n.string( + "mobile.settings.terminalFilesChip", + defaultValue: "Terminal Files Chip" + )) + } + .accessibilityIdentifier("MobileSettingsTerminalFilesChip") + } + #if DEBUG Section(L10n.string("mobile.settings.developer", defaultValue: "Developer")) { Button { @@ -219,6 +229,14 @@ struct MobileSettingsView: View { #endif Section(L10n.string("mobile.settings.display", defaultValue: "Display")) { + Toggle(isOn: $displaySettings.showMissingFiles) { + Text(L10n.string( + "mobile.settings.showMissingFiles", + defaultValue: "Show missing files" + )) + } + .accessibilityIdentifier("MobileSettingsShowMissingFiles") + Toggle(isOn: $displaySettings.wrapWorkspaceTitles) { Text(L10n.string("mobile.settings.wrapTitles", defaultValue: "Wrap Workspace Titles")) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings index f992b8a15def..629e26b1f7c5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Resources/Localizable.xcstrings @@ -70,6 +70,68 @@ } } }, + "terminal.artifact.gallery.action_failed.message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Check the connection to your Mac and try again." } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "Macへの接続を確認して、もう一度お試しください。" } } + } + }, + "terminal.artifact.gallery.action_failed.title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Couldn't complete action" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "操作を完了できませんでした" } } + } + }, + "terminal.artifact.gallery.browse_folder" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Browse folder" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フォルダを表示" } } + } + }, + "terminal.artifact.gallery.child_count" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "^[%lld item](inflect: true)" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld項目" + } + } + } + }, + "terminal.artifact.gallery.child_count_capped" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld+ items" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld項目以上" + } + } + } + }, + "terminal.artifact.gallery.copy_path" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Copy path" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "パスをコピー" } } + } + }, "terminal.artifact.gallery.empty" : { "extractionState" : "manual", "localizations" : { @@ -104,6 +166,48 @@ } } }, + "terminal.artifact.gallery.filter.all" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "All" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべて" } } + } + }, + "terminal.artifact.gallery.filter.code" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Code" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "コード" } } + } + }, + "terminal.artifact.gallery.filter.docs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Docs" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "書類" } } + } + }, + "terminal.artifact.gallery.filter.folders" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Folders" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "フォルダ" } } + } + }, + "terminal.artifact.gallery.filter.images" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Images" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "画像" } } + } + }, + "terminal.artifact.gallery.filter.logs" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Logs" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "ログ" } } + } + }, "terminal.artifact.gallery.kind.binary" : { "extractionState" : "manual", "localizations" : { @@ -206,6 +310,13 @@ } } }, + "terminal.artifact.gallery.loading_all" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Loading all files…" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "すべてのファイルを読み込み中…" } } + } + }, "terminal.artifact.gallery.missing" : { "extractionState" : "manual", "localizations" : { @@ -223,6 +334,20 @@ } } }, + "terminal.artifact.gallery.new_files" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "%lld new files" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "新しいファイル%lld件" } } + } + }, + "terminal.artifact.gallery.ok" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "OK" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "OK" } } + } + }, "terminal.artifact.gallery.provenance.attached" : { "extractionState" : "manual", "localizations" : { @@ -299,6 +424,48 @@ } } }, + "terminal.artifact.gallery.showing_first" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Showing first %lld files" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最初の%lld件のファイルを表示" } } + } + }, + "terminal.artifact.gallery.sort" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Sort" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "並べ替え" } } + } + }, + "terminal.artifact.gallery.share" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Share" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "共有" } } + } + }, + "terminal.artifact.gallery.sort.name" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Name" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "名前" } } + } + }, + "terminal.artifact.gallery.sort.recent" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Recent" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "最近" } } + } + }, + "terminal.artifact.gallery.sort.size" : { + "extractionState" : "manual", + "localizations" : { + "en" : { "stringUnit" : { "state" : "translated", "value" : "Size" } }, + "ja" : { "stringUnit" : { "state" : "translated", "value" : "サイズ" } } + } + }, "terminal.artifact.gallery.search" : { "extractionState" : "manual", "localizations" : { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift new file mode 100644 index 000000000000..340821834e07 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChildCountFormatter.swift @@ -0,0 +1,30 @@ +#if os(iOS) +import Foundation + +/// Resolves localized folder-child inflection markup before gallery rendering. +struct TerminalArtifactChildCountFormatter: Sendable { + private let locale: Locale + + init(locale: Locale = .autoupdatingCurrent) { + self.locale = locale + } + + func string(count: Int, isCapped: Bool) -> String { + if isCapped { + return String( + localized: "terminal.artifact.gallery.child_count_capped", + defaultValue: "\(count)+ items", + bundle: .module, + locale: locale + ) + } + let attributed = AttributedString( + localized: "terminal.artifact.gallery.child_count", + defaultValue: "^[\(count) item](inflect: true)", + bundle: .module, + locale: locale + ) + return String(attributed.characters) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift index 00d92aa30570..0a3c2a3fd89b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipCountState.swift @@ -73,7 +73,8 @@ struct TerminalArtifactChipCountState: Sendable { mutating func complete( _ request: Request, sessionTotal: Int?, - currentSurfaceGeneration: UInt64 + currentSurfaceGeneration: UInt64, + freshestLocalCount: Int ) -> Completion { guard request.stateGeneration == stateGeneration, inFlight == request else { @@ -110,7 +111,7 @@ struct TerminalArtifactChipCountState: Sendable { consecutiveRearmCount += 1 let nextRequest = makeRequest(Pending( surfaceGeneration: currentSurfaceGeneration, - localCount: request.localCount + localCount: freshestLocalCount )) inFlight = nextRequest return Completion(outcome: outcome, nextRequest: nextRequest) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipFeatureGate.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipFeatureGate.swift new file mode 100644 index 000000000000..b4a9f6c50bae --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactChipFeatureGate.swift @@ -0,0 +1,20 @@ +/// Owns the beta preference and capability checks for the terminal files chip. +/// +/// The coordinator uses this single seam both to decide whether the chip can +/// mount and to guard the count-only artifact scan that feeds it. +struct TerminalArtifactChipFeatureGate: Equatable, Sendable { + let isEnabled: Bool + + init(artifactsAvailable: Bool, preferenceEnabled: Bool) { + self.isEnabled = artifactsAvailable && preferenceEnabled + } + + /// Runs the chip's count scan only while the feature gate is enabled. + @MainActor + func performScan( + _ scan: @MainActor () async throws -> Result? + ) async rethrows -> Result? { + guard isEnabled else { return nil } + return try await scan() + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift index 723d6dfb8cdb..552150ce3981 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet+Content.swift @@ -31,46 +31,22 @@ extension TerminalArtifactFilesSheet { .padding(.vertical, 10) } - // Keep the existing single toolbar menu so the content picker never creates - // a second glass-backed segmented control in the navigation bar. var viewModePicker: some View { - Menu { - Picker( - String( - localized: "terminal.artifact.gallery.view_mode", - defaultValue: "View", - bundle: .module - ), - selection: $viewMode - ) { - Label( - String( - localized: "terminal.artifact.gallery.view_mode.list", - defaultValue: "List", - bundle: .module - ), - systemImage: "list.bullet" - ) - .tag(ViewMode.list) - - Label( - String( + Button { + viewMode = viewMode == .list ? .grid : .list + } label: { + Image(systemName: viewMode == .list ? "square.grid.3x3" : "list.bullet") + .accessibilityLabel(viewMode == .list + ? String( localized: "terminal.artifact.gallery.view_mode.grid", defaultValue: "Icons", bundle: .module - ), - systemImage: "square.grid.2x2" - ) - .tag(ViewMode.grid) - } - .pickerStyle(.inline) - } label: { - Image(systemName: viewMode == .list ? "list.bullet" : "square.grid.2x2") - .accessibilityLabel(String( - localized: "terminal.artifact.gallery.view_mode", - defaultValue: "View", - bundle: .module - )) + ) + : String( + localized: "terminal.artifact.gallery.view_mode.list", + defaultValue: "List", + bundle: .module + )) } } @@ -122,10 +98,12 @@ extension TerminalArtifactFilesSheet { systemImage: "tray" ) } else { + let swipeOrder = ChatArtifactGallerySwipeOrder(references: artifacts) artifactCollection( artifacts.map(TerminalArtifactGalleryDisplayItem.init(reference:)), loader: loader, - scope: .inView + scope: .inView, + swipeOrder: swipeOrder ) .refreshable { await refreshInView() } } @@ -137,10 +115,26 @@ extension TerminalArtifactFilesSheet { @ViewBuilder private var sessionContent: some View { let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) - if query.isEmpty { - sessionSectionedContent(state: sessionState) - } else { - sessionSearchContent(state: searchState, query: query) + VStack(spacing: 0) { + galleryControls + if eagerPagingState == .loading { + ProgressView() + .progressViewStyle(.linear) + .accessibilityLabel(String( + localized: "terminal.artifact.gallery.loading_all", + defaultValue: "Loading all files…", + bundle: .module + )) + } + Divider() + if query.isEmpty { + sessionSectionedContent(state: sessionState) + } else { + sessionSearchContent(state: searchState, query: query) + } + } + .task(id: eagerPagingTaskID) { + await loadRemainingSessionPages(query: query.isEmpty ? nil : query) } } @@ -152,7 +146,16 @@ extension TerminalArtifactFilesSheet { case .failed: failureView { await loadFirstSessionPage(query: nil) } case .loaded(let snapshot): - if snapshot.isEmpty { + let visibleSnapshotIsEmpty = displaySettings.showMissingFiles + ? snapshot.isEmpty + : ChatArtifactGalleryPresentation(snapshot: snapshot).isEmpty + let presentation = ChatArtifactGalleryPresentation( + snapshot: snapshot, + filter: galleryFilter, + sort: gallerySort, + includesMissingFiles: displaySettings.showMissingFiles + ) + if visibleSnapshotIsEmpty { ScrollView { ContentUnavailableView( String( @@ -168,17 +171,28 @@ extension TerminalArtifactFilesSheet { await loadFirstSessionPage(query: nil, preservingContent: true) } } else { - ScrollView { - VStack(spacing: 0) { + let created = presentation.items(in: .created) + let attached = presentation.items(in: .attached) + let referenced = presentation.items(in: .referenced) + let swipeOrder = ChatArtifactGallerySwipeOrder(groups: presentation.groups) + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + Color.clear + .frame(height: 0) + .id(Self.sessionScrollTopID) artifactSection( title: String( localized: "terminal.artifact.gallery.section.created", defaultValue: "Created by agent", bundle: .module ), - count: snapshot.created.count, - items: snapshot.created, - expanded: $createdExpanded + count: displaySettings.showMissingFiles + ? (usesCompleteSessionSnapshot ? created.count : snapshot.createdTotal) + : nil, + items: created, + expanded: $createdExpanded, + swipeOrder: swipeOrder ) artifactSection( title: String( @@ -186,9 +200,12 @@ extension TerminalArtifactFilesSheet { defaultValue: "You attached", bundle: .module ), - count: snapshot.attached.count, - items: snapshot.attached, - expanded: $attachedExpanded + count: displaySettings.showMissingFiles + ? (usesCompleteSessionSnapshot ? attached.count : snapshot.attachedTotal) + : nil, + items: attached, + expanded: $attachedExpanded, + swipeOrder: swipeOrder ) artifactSection( title: String( @@ -196,12 +213,34 @@ extension TerminalArtifactFilesSheet { defaultValue: "Referenced", bundle: .module ), - count: snapshot.referencedTotal, - items: snapshot.referenced, + count: displaySettings.showMissingFiles + ? (usesCompleteSessionSnapshot + ? referenced.count + : snapshot.referencedTotal) + : nil, + items: referenced, expanded: $referencedExpanded, - pagingCursor: snapshot.nextCursor + swipeOrder: swipeOrder, + pagingCursor: usesCompleteSessionSnapshot ? nil : snapshot.nextCursor, + showsEagerFooter: usesCompleteSessionSnapshot ) } + } + .onScrollGeometryChange(for: Bool.self) { geometry in + let isAtTop = geometry.contentOffset.y + <= geometry.contentInsets.top + Self.sessionTopTolerance + let fits = geometry.contentSize.height + <= geometry.containerSize.height + Self.sessionTopTolerance + return isAtTop || fits + } action: { _, isAtTopOrFits in + sessionViewportIsAtTopOrFits = isAtTopOrFits + } + .overlay(alignment: .top) { + if liveRefreshState.pendingNewFileCount > 0 { + newFilesPill(snapshot: snapshot, proxy: proxy) + .padding(.top, 8) + } + } } .refreshable { await loadFirstSessionPage(query: nil, preservingContent: true) @@ -210,6 +249,37 @@ extension TerminalArtifactFilesSheet { } } + private func newFilesPill( + snapshot: SessionGallerySnapshot, + proxy: ScrollViewProxy + ) -> some View { + let format = String( + localized: "terminal.artifact.gallery.new_files", + defaultValue: "%lld new files", + bundle: .module + ) + let title = String.localizedStringWithFormat( + format, + Int64(liveRefreshState.pendingNewFileCount) + ) + return Button { + guard let reconciled = liveRefreshState.applyPending(to: snapshot) else { return } + withAnimation(.easeInOut(duration: 0.25)) { + sessionState = .loaded(reconciled) + proxy.scrollTo(Self.sessionScrollTopID, anchor: .top) + } + } label: { + Label(title, systemImage: "arrow.up") + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.regularMaterial, in: Capsule()) + .shadow(color: .black.opacity(0.12), radius: 5, y: 2) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + } + @ViewBuilder private func sessionSearchContent(state: SessionLoadState, query: String) -> some View { switch state { @@ -218,13 +288,21 @@ extension TerminalArtifactFilesSheet { case .failed: failureView { await loadFirstSessionPage(query: query) } case .loaded(let snapshot): - if snapshot.referenced.isEmpty { + let presentation = ChatArtifactGalleryPresentation( + snapshot: snapshot, + filter: galleryFilter, + sort: gallerySort, + includesMissingFiles: displaySettings.showMissingFiles + ) + let items = presentation.items(in: .referenced) + let swipeOrder = ChatArtifactGallerySwipeOrder(items: items) + if items.isEmpty { ContentUnavailableView.search(text: query) } else { ScrollView { if viewMode == .list { LazyVStack(spacing: 0) { - ForEach(snapshot.referenced) { item in + ForEach(items) { item in TerminalArtifactGalleryItemView( artifact: TerminalArtifactGalleryDisplayItem( galleryItem: item, @@ -232,18 +310,23 @@ extension TerminalArtifactFilesSheet { ), layout: .list, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) Divider().padding(.leading, 72) } - if let cursor = snapshot.nextCursor { + if !usesCompleteSessionSnapshot, + let cursor = snapshot.nextCursor { pagingFooter(cursor: cursor, query: query) + } else if usesCompleteSessionSnapshot { + eagerPagingFooter } } } else { LazyVGrid(columns: gridColumns, spacing: 16) { Section { - ForEach(snapshot.referenced) { item in + ForEach(items) { item in TerminalArtifactGalleryItemView( artifact: TerminalArtifactGalleryDisplayItem( galleryItem: item, @@ -251,12 +334,17 @@ extension TerminalArtifactFilesSheet { ), layout: .grid, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) } } footer: { - if let cursor = snapshot.nextCursor { + if !usesCompleteSessionSnapshot, + let cursor = snapshot.nextCursor { pagingFooter(cursor: cursor, query: query) + } else if usesCompleteSessionSnapshot { + eagerPagingFooter } } } @@ -272,10 +360,12 @@ extension TerminalArtifactFilesSheet { private func artifactSection( title: String, - count: Int, + count: Int?, items: [ChatArtifactGalleryItem], expanded: Binding, - pagingCursor: String? = nil + swipeOrder: ChatArtifactGallerySwipeOrder, + pagingCursor: String? = nil, + showsEagerFooter: Bool = false ) -> some View { DisclosureGroup(isExpanded: expanded) { if viewMode == .list { @@ -285,12 +375,16 @@ extension TerminalArtifactFilesSheet { artifact: TerminalArtifactGalleryDisplayItem(galleryItem: item), layout: .list, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) Divider().padding(.leading, 72) } if let pagingCursor { pagingFooter(cursor: pagingCursor, query: nil) + } else if showsEagerFooter { + eagerPagingFooter } } } else { @@ -301,19 +395,23 @@ extension TerminalArtifactFilesSheet { artifact: TerminalArtifactGalleryDisplayItem(galleryItem: item), layout: .grid, loader: sessionLoader, - open: { open(item.path, scope: .session) } + open: { + open(item.path, scope: .session, swipeOrder: swipeOrder) + } ) } } footer: { if let pagingCursor { pagingFooter(cursor: pagingCursor, query: nil) + } else if showsEagerFooter { + eagerPagingFooter } } } .padding(.vertical, 12) } } label: { - Text(verbatim: "\(title) (\(count))") + Text(verbatim: count.map { "\(title) (\($0))" } ?? title) .font(.headline) } .padding(.horizontal, 16) @@ -324,7 +422,8 @@ extension TerminalArtifactFilesSheet { private func artifactCollection( _ artifacts: [TerminalArtifactGalleryDisplayItem], loader: ChatArtifactLoader, - scope: Scope + scope: Scope, + swipeOrder: ChatArtifactGallerySwipeOrder ) -> some View { switch viewMode { case .list: @@ -335,7 +434,9 @@ extension TerminalArtifactFilesSheet { artifact: artifact, layout: .list, loader: loader, - open: { open(artifact.path, scope: scope) } + open: { + open(artifact.path, scope: scope, swipeOrder: swipeOrder) + } ) Divider().padding(.leading, 72) } @@ -349,7 +450,9 @@ extension TerminalArtifactFilesSheet { artifact: artifact, layout: .grid, loader: loader, - open: { open(artifact.path, scope: scope) } + open: { + open(artifact.path, scope: scope, swipeOrder: swipeOrder) + } ) } } @@ -371,6 +474,43 @@ extension TerminalArtifactFilesSheet { } } + @ViewBuilder + private var eagerPagingFooter: some View { + switch eagerPagingState { + case .idle, .loading: + EmptyView() + case .capped: + let format = String( + localized: "terminal.artifact.gallery.showing_first", + defaultValue: "Showing first %lld files", + bundle: .module + ) + Text(String.localizedStringWithFormat( + format, + Int64(ChatArtifactGalleryEagerPager.defaultMaximumReferencedRows) + )) + .font(.footnote) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding() + case .failed: + Button { + eagerPagingRetryGeneration += 1 + } label: { + Label( + String( + localized: "terminal.artifact.gallery.retry", + defaultValue: "Retry", + bundle: .module + ), + systemImage: "arrow.clockwise" + ) + } + .frame(maxWidth: .infinity) + .padding() + } + } + private var loadingView: some View { ProgressView(String( localized: "terminal.artifact.gallery.loading", @@ -414,7 +554,89 @@ extension TerminalArtifactFilesSheet { } private var gridColumns: [GridItem] { - [GridItem(.adaptive(minimum: 96), spacing: 12)] + Array( + repeating: GridItem(.flexible(minimum: 0), spacing: 12, alignment: .top), + count: 3 + ) + } + + private static let sessionScrollTopID = "terminal-artifact-gallery-top" + private static let sessionTopTolerance: CGFloat = 1 + + private var galleryControls: some View { + HStack(spacing: 12) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(ChatArtifactGalleryFilter.allCases, id: \.self) { filter in + Button { + galleryFilter = filter + } label: { + Text(filterTitle(filter)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(galleryFilter == filter ? Color.white : Color.primary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background( + galleryFilter == filter + ? Color.accentColor + : Color(uiColor: .secondarySystemBackground), + in: Capsule() + ) + } + .buttonStyle(.plain) + .accessibilityAddTraits(galleryFilter == filter ? .isSelected : []) + } + } + } + + Menu { + Picker( + String( + localized: "terminal.artifact.gallery.sort", + defaultValue: "Sort", + bundle: .module + ), + selection: $gallerySort + ) { + ForEach(ChatArtifactGallerySort.allCases, id: \.self) { sort in + Text(sortTitle(sort)).tag(sort) + } + } + } label: { + Label(sortTitle(gallerySort), systemImage: "arrow.up.arrow.down") + .font(.subheadline.weight(.medium)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + + private func filterTitle(_ filter: ChatArtifactGalleryFilter) -> String { + switch filter { + case .all: + String(localized: "terminal.artifact.gallery.filter.all", defaultValue: "All", bundle: .module) + case .images: + String(localized: "terminal.artifact.gallery.filter.images", defaultValue: "Images", bundle: .module) + case .code: + String(localized: "terminal.artifact.gallery.filter.code", defaultValue: "Code", bundle: .module) + case .logs: + String(localized: "terminal.artifact.gallery.filter.logs", defaultValue: "Logs", bundle: .module) + case .docs: + String(localized: "terminal.artifact.gallery.filter.docs", defaultValue: "Docs", bundle: .module) + case .folders: + String(localized: "terminal.artifact.gallery.filter.folders", defaultValue: "Folders", bundle: .module) + } + } + + private func sortTitle(_ sort: ChatArtifactGallerySort) -> String { + switch sort { + case .recent: + String(localized: "terminal.artifact.gallery.sort.recent", defaultValue: "Recent", bundle: .module) + case .name: + String(localized: "terminal.artifact.gallery.sort.name", defaultValue: "Name", bundle: .module) + case .size: + String(localized: "terminal.artifact.gallery.sort.size", defaultValue: "Size", bundle: .module) + } } private func searchSubtitle(for item: ChatArtifactGalleryItem) -> String { @@ -443,8 +665,17 @@ extension TerminalArtifactFilesSheet { return "\(provenance) · \(modifiedAt.formatted(date: .abbreviated, time: .omitted))" } - private func open(_ path: String, scope: Scope) { - selection = TerminalArtifactPathSelection(path: path, scope: scope) + private func open( + _ path: String, + scope: Scope, + swipeOrder: ChatArtifactGallerySwipeOrder + ) { + selection = TerminalArtifactPathSelection( + path: path, + scope: scope, + usesSessionAuthorization: scope == .session || sessionID != nil, + swipeOrder: swipeOrder + ) } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift index 6036143d4384..c840f11afdd1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactFilesSheet.swift @@ -2,6 +2,7 @@ import CmuxAgentChat import CmuxAgentChatUI import CmuxMobileShell +import Foundation import SwiftUI struct TerminalArtifactContext: Identifiable { @@ -16,14 +17,42 @@ struct TerminalArtifactSelection: Identifiable, Equatable { let workspaceID: String let surfaceID: String let path: String + let sessionID: String? - var id: String { "\(workspaceID)#\(surfaceID)#\(path)" } + init( + workspaceID: String, + surfaceID: String, + path: String, + session: ChatSessionDescriptor? + ) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + + if (path as NSString).isAbsolutePath { + self.path = (path as NSString).standardizingPath + sessionID = session?.id + } else if let session, + let workingDirectory = session.workingDirectory, + (workingDirectory as NSString).isAbsolutePath { + self.path = ((workingDirectory as NSString).appendingPathComponent(path) as NSString) + .standardizingPath + sessionID = session.id + } else { + self.path = path + sessionID = nil + } + } + + var usesSessionAuthorization: Bool { sessionID != nil } + + var id: String { "\(workspaceID)#\(surfaceID)#\(sessionID ?? "terminal")#\(path)" } } struct TerminalArtifactFilesSheet: View { let workspaceID: String let surfaceID: String let source: MobileChatEventSource? + let refreshSignal: TerminalArtifactGalleryRefreshSignal let loader: ChatArtifactLoader @State var inViewState: InViewLoadState = .loading @@ -33,14 +62,38 @@ struct TerminalArtifactFilesSheet: View { @State var sessionLoader = ChatArtifactLoader.unsupported() @State var scope: Scope = .session @State var viewMode: ViewMode = .list + @State var galleryFilter: ChatArtifactGalleryFilter = .all + @State var gallerySort: ChatArtifactGallerySort = .recent + @State var eagerPagingState: EagerPagingState = .idle + @State var eagerPagingRetryGeneration = 0 + @State var eagerPagingRevision = 0 @State var searchQuery = "" @State var selection: TerminalArtifactPathSelection? @State var createdExpanded = true @State var attachedExpanded = true @State var referencedExpanded = true @State var thumbnailPrefetchTasks: [Task] = [] + @State var liveRefreshState = ChatArtifactGalleryLiveRefreshState() + @State var sessionViewportIsAtTopOrFits = true + @State var lastHandledRefreshSignal: TerminalArtifactGalleryRefreshSignal + @Environment(MobileDisplaySettings.self) var displaySettings @Environment(\.dismiss) private var dismiss + init( + workspaceID: String, + surfaceID: String, + source: MobileChatEventSource?, + refreshSignal: TerminalArtifactGalleryRefreshSignal, + loader: ChatArtifactLoader + ) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.source = source + self.refreshSignal = refreshSignal + self.loader = loader + _lastHandledRefreshSignal = State(initialValue: refreshSignal) + } + var body: some View { NavigationStack { VStack(spacing: 0) { @@ -70,20 +123,28 @@ struct TerminalArtifactFilesSheet: View { viewModePicker } } + .navigationDestination(isPresented: artifactIsPresented) { + if let selection { + ChatArtifactViewerDestination( + path: selection.path, + scope: selection.usesSessionAuthorization ? .chat : .terminal, + swipeOrder: selection.swipeOrder + ) { + dismiss() + } + .environment( + \.chatArtifactLoader, + selection.usesSessionAuthorization ? sessionLoader : loader + ) + } + } } .frame(idealWidth: 380, idealHeight: 520) .task(id: "\(workspaceID)#\(surfaceID)") { await loadInitial() } - .sheet(item: $selection) { selection in - ChatArtifactViewerSheet( - path: selection.path, - scope: selection.scope == .session ? .chat : .terminal - ) - .environment( - \.chatArtifactLoader, - selection.scope == .session ? sessionLoader : loader - ) + .task(id: liveRefreshTaskID) { + await refreshSessionForLiveSignal() } .onDisappear { thumbnailPrefetchTasks.forEach { $0.cancel() } @@ -91,6 +152,15 @@ struct TerminalArtifactFilesSheet: View { } } + private var artifactIsPresented: Binding { + Binding( + get: { selection != nil }, + set: { isPresented in + if !isPresented { selection = nil } + } + ) + } + private func loadInitial() async { guard let source else { @@ -106,7 +176,9 @@ struct TerminalArtifactFilesSheet: View { visibleOnly: true ) guard !Task.isCancelled else { return } - let files = response.artifacts.filter { $0.kind != .directory } + let files = source.supportsTerminalArtifactList + ? response.artifacts + : response.artifacts.filter { $0.kind != .directory } inViewState = .loaded(files) guard source.supportsArtifactGallery, let resolvedSessionID = response.sessionID?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -139,7 +211,10 @@ struct TerminalArtifactFilesSheet: View { visibleOnly: true ) guard !Task.isCancelled else { return } - inViewState = .loaded(response.artifacts.filter { $0.kind != .directory }) + let files = source.supportsTerminalArtifactList + ? response.artifacts + : response.artifacts.filter { $0.kind != .directory } + inViewState = .loaded(files) } catch is CancellationError { return } catch { @@ -172,8 +247,10 @@ struct TerminalArtifactFilesSheet: View { guard query == searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) else { return } searchState = .loaded(snapshot) } else { + liveRefreshState.reset() sessionState = .loaded(snapshot) } + eagerPagingRevision += 1 startThumbnailPrefetch(page.referenced) } catch is CancellationError { return @@ -188,6 +265,46 @@ struct TerminalArtifactFilesSheet: View { } } + func refreshSessionForLiveSignal() async { + guard refreshSignal != lastHandledRefreshSignal, + scope == .session, + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let source, + let sessionID, + case .loaded(let displayed) = sessionState else { return } + do { + let page = try await source.chatArtifactGallery( + sessionID: sessionID, + cursor: nil, + pageSize: Self.pageSize, + query: nil + ) + guard !Task.isCancelled else { return } + let fresh = SessionGallerySnapshot(page: page) + lastHandledRefreshSignal = refreshSignal + if let reconciled = liveRefreshState.receive( + fresh: fresh, + displayed: displayed, + isAtTopOrFits: sessionViewportIsAtTopOrFits + ) { + withAnimation(.easeInOut(duration: 0.25)) { + sessionState = .loaded(reconciled) + } + } + let displayedPaths = Set((displayed.created + displayed.attached + displayed.referenced).map(\.path)) + startThumbnailPrefetch( + (fresh.created + fresh.attached + fresh.referenced).filter { + !displayedPaths.contains($0.path) + } + ) + } catch is CancellationError { + return + } catch { + // Keep the current gallery stable. The next accepted chip signal + // retries against a newer terminal/session generation. + } + } + func loadNextSessionPage(cursor: String, query: String?) async { guard let source, let sessionID else { return } do { @@ -202,11 +319,19 @@ struct TerminalArtifactFilesSheet: View { guard query == searchQuery.trimmingCharacters(in: .whitespacesAndNewlines), case .loaded(let current) = searchState, current.nextCursor == cursor else { return } + if page.requiresPagingRestart { + await restartPaging(after: cursor, query: query) + return + } searchState = .loaded(current.appending(page)) } else { guard searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, case .loaded(let current) = sessionState, current.nextCursor == cursor else { return } + if page.requiresPagingRestart { + await restartPaging(after: cursor, query: nil) + return + } sessionState = .loaded(current.appending(page)) } startThumbnailPrefetch(page.referenced) @@ -218,6 +343,135 @@ struct TerminalArtifactFilesSheet: View { } } + func loadRemainingSessionPages(query: String?) async { + guard let source, let sessionID, usesCompleteSessionSnapshot else { + eagerPagingState = .idle + return + } + let initialState = query == nil ? sessionState : searchState + guard case .loaded(let initialSnapshot) = initialState else { + eagerPagingState = .idle + return + } + guard initialSnapshot.nextCursor != nil else { + eagerPagingState = initialSnapshot.referenced.count + < initialSnapshot.referencedTotal ? .capped : .idle + return + } + + let expectedFilter = galleryFilter + let expectedSort = gallerySort + let expectedShowMissingFiles = displaySettings.showMissingFiles + let expectedQuery = query + eagerPagingState = .loading + do { + let result = try await ChatArtifactGalleryEagerPager().loadRemaining( + from: initialSnapshot + ) { cursor in + try await source.chatArtifactGallery( + sessionID: sessionID, + cursor: cursor, + pageSize: Self.pageSize, + query: expectedQuery + ) + } + if result.requiresPagingRestart { + await restartPaging( + after: initialSnapshot.nextCursor, + query: expectedQuery + ) + return + } + let currentQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let queryIsCurrent = expectedQuery.map { $0 == currentQuery } + ?? currentQuery.isEmpty + guard !Task.isCancelled, + galleryFilter == expectedFilter, + gallerySort == expectedSort, + displaySettings.showMissingFiles == expectedShowMissingFiles, + queryIsCurrent else { return } + let currentState = query == nil ? sessionState : searchState + guard case .loaded(let currentSnapshot) = currentState, + currentSnapshot.generation == initialSnapshot.generation, + currentSnapshot.nextCursor == initialSnapshot.nextCursor else { return } + if query == nil { + sessionState = .loaded(result.snapshot) + } else { + searchState = .loaded(result.snapshot) + } + let previousPaths = Set(initialSnapshot.referenced.map(\.path)) + startThumbnailPrefetch( + result.snapshot.referenced.filter { !previousPaths.contains($0.path) } + ) + if result.reachedSafetyCap { + eagerPagingState = .capped + } else if result.snapshot.nextCursor != nil { + // A defensive cursor-loop stop is incomplete, so surface the + // existing retry affordance instead of presenting partial + // transformed results as complete. + eagerPagingState = .failed + } else { + eagerPagingState = .idle + } + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + eagerPagingState = .failed + } + } + + private func restartPaging(after staleCursor: String?, query: String?) async { + guard let staleCursor, let source, let sessionID else { return } + let currentQuery = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard query.map({ $0 == currentQuery }) ?? currentQuery.isEmpty else { return } + let state = query == nil ? sessionState : searchState + guard case .loaded(let displayed) = state, + displayed.nextCursor == staleCursor else { return } + do { + let page = try await source.chatArtifactGallery( + sessionID: sessionID, + cursor: nil, + pageSize: Self.pageSize, + query: query + ) + guard !Task.isCancelled else { return } + let currentState = query == nil ? sessionState : searchState + guard case .loaded(let current) = currentState, + current.nextCursor == staleCursor else { return } + let fresh = SessionGallerySnapshot(page: page) + if query == nil { + if sessionViewportIsAtTopOrFits { + liveRefreshState.reset() + withAnimation(.easeInOut(duration: 0.25)) { + sessionState = .loaded(current.reconciling(withFreshFirstPage: fresh)) + } + } else { + _ = liveRefreshState.receive( + fresh: fresh, + displayed: current, + isAtTopOrFits: false + ) + sessionState = .loaded(current.rebasingPaging(ontoFreshFirstPage: fresh)) + } + } else { + searchState = .loaded(current.restartingPaging(withFreshFirstPage: fresh)) + } + eagerPagingState = .idle + eagerPagingRevision += 1 + let currentPaths = Set((current.created + current.attached + current.referenced).map(\.path)) + startThumbnailPrefetch( + (fresh.created + fresh.attached + fresh.referenced).filter { + !currentPaths.contains($0.path) + } + ) + } catch is CancellationError { + return + } catch { + eagerPagingState = .failed + } + } + private func startThumbnailPrefetch(_ items: [ChatArtifactGalleryItem]) { let loader = sessionLoader let task = Task(priority: .low) { @@ -239,6 +493,42 @@ struct TerminalArtifactFilesSheet: View { static let pageSize = 60 + var usesCompleteSessionSnapshot: Bool { + galleryFilter != .all + || gallerySort != .recent + || !displaySettings.showMissingFiles + } + + var eagerPagingTaskID: String { + let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let state = query.isEmpty ? sessionState : searchState + let cursor: String + if case .loaded(let snapshot) = state { + cursor = "\(snapshot.generation)#\(snapshot.nextCursor ?? "exhausted")" + } else { + cursor = "unloaded" + } + return [ + galleryFilter.rawValue, + gallerySort.rawValue, + String(displaySettings.showMissingFiles), + query, + cursor, + String(eagerPagingRevision), + String(eagerPagingRetryGeneration), + ].joined(separator: "#") + } + + var liveRefreshTaskID: String { + [ + String(refreshSignal.surfaceGeneration), + String(refreshSignal.count), + sessionID ?? "unresolved", + String(describing: scope), + searchQuery.trimmingCharacters(in: .whitespacesAndNewlines), + ].joined(separator: "#") + } + enum InViewLoadState: Equatable { case loading case loaded([TerminalArtifactReference]) @@ -252,54 +542,7 @@ struct TerminalArtifactFilesSheet: View { case failed } - struct SessionGallerySnapshot: Equatable { - let created: [ChatArtifactGalleryItem] - let attached: [ChatArtifactGalleryItem] - let referenced: [ChatArtifactGalleryItem] - let referencedTotal: Int - let nextCursor: String? - let generation: String - - var isEmpty: Bool { - created.isEmpty && attached.isEmpty && referencedTotal == 0 - } - - init(page: ChatArtifactGalleryPage) { - created = page.created - attached = page.attached - referenced = page.referenced - referencedTotal = page.referencedTotal - nextCursor = page.nextCursor - generation = page.generation - } - - func appending(_ page: ChatArtifactGalleryPage) -> SessionGallerySnapshot { - SessionGallerySnapshot( - created: created, - attached: attached, - referenced: referenced + page.referenced, - referencedTotal: page.referencedTotal, - nextCursor: page.nextCursor, - generation: page.generation - ) - } - - private init( - created: [ChatArtifactGalleryItem], - attached: [ChatArtifactGalleryItem], - referenced: [ChatArtifactGalleryItem], - referencedTotal: Int, - nextCursor: String?, - generation: String - ) { - self.created = created - self.attached = attached - self.referenced = referenced - self.referencedTotal = referencedTotal - self.nextCursor = nextCursor - self.generation = generation - } - } + typealias SessionGallerySnapshot = ChatArtifactGallerySnapshot enum Scope: Hashable { case inView @@ -311,10 +554,19 @@ struct TerminalArtifactFilesSheet: View { case grid } + enum EagerPagingState: Equatable { + case idle + case loading + case capped + case failed + } + struct TerminalArtifactPathSelection: Identifiable { let path: String let scope: Scope - var id: String { "\(scope)#\(path)" } + let usesSessionAuthorization: Bool + let swipeOrder: ChatArtifactGallerySwipeOrder + var id: String { "\(scope)#\(usesSessionAuthorization)#\(path)" } } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift index 7a0e1ff8437e..a2c09e655249 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryDisplayItem.swift @@ -10,6 +10,8 @@ struct TerminalArtifactGalleryDisplayItem: Identifiable, Equatable { let size: Int64? let modifiedAt: Date? let exists: Bool + let childCount: Int? + let childCountIsCapped: Bool let subtitle: String? var id: String { path } @@ -21,6 +23,8 @@ struct TerminalArtifactGalleryDisplayItem: Identifiable, Equatable { size = reference.size modifiedAt = reference.modifiedAt exists = true + childCount = nil + childCountIsCapped = false subtitle = nil } @@ -31,6 +35,8 @@ struct TerminalArtifactGalleryDisplayItem: Identifiable, Equatable { size = galleryItem.size modifiedAt = galleryItem.modifiedAt exists = galleryItem.exists + childCount = galleryItem.childCount + childCountIsCapped = galleryItem.childCountIsCapped self.subtitle = subtitle } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift index 335dedf09fc2..581d6445a463 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryItemView.swift @@ -18,6 +18,9 @@ struct TerminalArtifactGalleryItemView: View { let open: () -> Void @State private var thumbnail: ChatArtifactThumbnail? + @State private var fileActionPresentation: ChatArtifactFileActionPresentation? + @State private var isFileActionRunning = false + @State private var showsFileActionError = false @ScaledMetric(relativeTo: .subheadline) private var gridNameMinHeight: CGFloat = 38 @ScaledMetric(relativeTo: .caption2) private var gridMetadataMinHeight: CGFloat = 28 @ScaledMetric(relativeTo: .body) private var gridSymbolSize: CGFloat = 48 @@ -37,6 +40,65 @@ struct TerminalArtifactGalleryItemView: View { .accessibilityLabel(artifact.displayName) .accessibilityValue(accessibilityDetail) .opacity(artifact.exists ? 1 : 0.5) + .contextMenu { + if artifact.kind != .directory { + Button { + shareFile() + } label: { + Label( + String( + localized: "terminal.artifact.gallery.share", + defaultValue: "Share", + bundle: .module + ), + systemImage: "square.and.arrow.up" + ) + } + .disabled(!artifact.exists || isFileActionRunning) + } + Button { + UIPasteboard.general.string = artifact.path + } label: { + Label( + String( + localized: "terminal.artifact.gallery.copy_path", + defaultValue: "Copy path", + bundle: .module + ), + systemImage: "link" + ) + } + if artifact.kind == .directory { + Button(action: open) { + Label( + String( + localized: "terminal.artifact.gallery.browse_folder", + defaultValue: "Browse folder", + bundle: .module + ), + systemImage: "folder" + ) + } + .disabled(!artifact.exists) + } + } + .chatArtifactFileActionPresentation($fileActionPresentation) + .alert( + String( + localized: "terminal.artifact.gallery.action_failed.title", + defaultValue: "Couldn't complete action", + bundle: .module + ), + isPresented: $showsFileActionError + ) { + Button(String(localized: "terminal.artifact.gallery.ok", defaultValue: "OK", bundle: .module)) {} + } message: { + Text(String( + localized: "terminal.artifact.gallery.action_failed.message", + defaultValue: "Check the connection to your Mac and try again.", + bundle: .module + )) + } .task(id: "\(artifact.path)#\(Self.thumbnailDimension)") { guard artifact.kind == .image, artifact.exists else { return } thumbnail = try? await loader.thumbnail( @@ -48,6 +110,26 @@ struct TerminalArtifactGalleryItemView: View { } } + private func shareFile() { + guard !isFileActionRunning else { return } + isFileActionRunning = true + Task { + do { + let fileURL = try await ChatArtifactFileActionStore.applicationDefault.materialize( + path: artifact.path, + loader: loader + ) + try Task.checkCancellation() + fileActionPresentation = .share(fileURL) + } catch is CancellationError { + // The row disappeared while its file was being prepared. + } catch { + showsFileActionError = true + } + isFileActionRunning = false + } + } + private var listContent: some View { HStack(spacing: 12) { preview @@ -154,18 +236,25 @@ struct TerminalArtifactGalleryItemView: View { } private var placeholderSymbol: some View { - Image(systemName: symbolName) + let glyph = ChatArtifactGalleryClassifier().glyphPresentation( + for: artifact.kind, + path: artifact.path + ) + return Image(systemName: glyph.systemImageName) .font(.system(size: layout == .grid ? gridSymbolSize : listSymbolSize, weight: .regular)) .symbolRenderingMode(.hierarchical) - .foregroundStyle(symbolTint) + .foregroundStyle(glyph.tint.swiftUIColor) } private var metadataText: String? { var components: [String] = [] + if artifact.kind == .directory, let childCount = artifact.childCount { + components.append(childCountText(childCount)) + } if let modifiedAt = artifact.modifiedAt { components.append(modifiedAt.formatted(date: .abbreviated, time: .omitted)) } - if let size = artifact.size { + if artifact.kind != .directory, let size = artifact.size { components.append(ByteCountFormatter.string( fromByteCount: max(0, size), countStyle: .file @@ -174,6 +263,13 @@ struct TerminalArtifactGalleryItemView: View { return components.isEmpty ? nil : components.joined(separator: " · ") } + private func childCountText(_ childCount: Int) -> String { + TerminalArtifactChildCountFormatter().string( + count: childCount, + isCapped: artifact.childCountIsCapped + ) + } + private var detailText: String? { artifact.subtitle ?? metadataText } @@ -210,28 +306,6 @@ struct TerminalArtifactGalleryItemView: View { } } - private var symbolTint: Color { - switch artifact.kind { - case .image, .binary: - .secondary - case .text, .directory: - .blue - } - } - - private var symbolName: String { - switch artifact.kind { - case .image: - return "photo" - case .text: - return "doc.text" - case .binary: - return "doc.fill" - case .directory: - return "folder" - } - } - private static let thumbnailDimension = 256 } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryRefreshSignal.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryRefreshSignal.swift new file mode 100644 index 000000000000..7297d0e58baf --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalArtifactGalleryRefreshSignal.swift @@ -0,0 +1,7 @@ +/// One generation-checked artifact count accepted by the terminal chip pipeline. +struct TerminalArtifactGalleryRefreshSignal: Sendable, Equatable { + let count: Int + let surfaceGeneration: UInt64 + + static let initial = TerminalArtifactGalleryRefreshSignal(count: 0, surfaceGeneration: 0) +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index 41b2d84065b0..ce657dfc93eb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -29,6 +29,7 @@ extension WorkspaceDetailView { // scrollback survives a theme change. configThemeGeneration: store.terminalConfigThemeGeneration, artifactFilesEnabled: store.supportsTerminalArtifacts, + terminalFilesChipEnabled: terminalFilesChipEnabled, sessionArtifactCountEnabled: store.supportsChatArtifactGallery, visibleArtifactCount: visibleArtifactCount, onArtifactFilesRequested: { anchor in @@ -42,13 +43,19 @@ extension WorkspaceDetailView { selectedTerminalArtifact = TerminalArtifactSelection( workspaceID: workspace.id.rawValue, surfaceID: terminalID, - path: path + path: path, + session: chosenChatSession ) }, onVisibleArtifactCountChanged: { count in if visibleArtifactCount != count { visibleArtifactCount = count } + }, + onArtifactGalleryRefreshSignal: { signal in + if artifactGalleryRefreshSignal != signal { + artifactGalleryRefreshSignal = signal + } } ) .popover( @@ -60,6 +67,7 @@ extension WorkspaceDetailView { workspaceID: context.workspaceID, surfaceID: context.surfaceID, source: store.makeChatEventSource(), + refreshSignal: artifactGalleryRefreshSignal, loader: terminalArtifactLoader( workspaceID: context.workspaceID, surfaceID: context.surfaceID diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index 3695df00ed96..b9bae4de0821 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -72,6 +72,7 @@ struct WorkspaceDetailView: View { @State var selectedTerminalArtifact: TerminalArtifactSelection? @State var terminalArtifactThumbnailCache = ChatArtifactThumbnailCache() @State var visibleArtifactCount = 0 + @State var artifactGalleryRefreshSignal = TerminalArtifactGalleryRefreshSignal.initial /// App lifecycle phase used to re-pull chat sessions on foreground. @Environment(\.scenePhase) var scenePhase #endif @@ -80,6 +81,9 @@ struct WorkspaceDetailView: View { browserStore.activeBrowser(for: workspace.id.rawValue) } #if os(iOS) + var terminalFilesChipEnabled: Bool { + displaySettings.terminalFilesChipEnabled + } var activeSurface: WorkspaceActiveSurface { WorkspaceActiveSurface.derive( isChatMode: isChatMode, @@ -264,15 +268,19 @@ struct WorkspaceDetailView: View { store.activeTerminalTheme.terminalBackgroundColor .ignoresSafeArea(.container, edges: [.horizontal, .top, .bottom]) } - .sheet(item: $selectedTerminalArtifact) { selection in - ChatArtifactViewerSheet(path: selection.path, scope: .terminal) - .environment( - \.chatArtifactLoader, - terminalArtifactLoader( - workspaceID: selection.workspaceID, - surfaceID: selection.surfaceID + .navigationDestination(isPresented: terminalArtifactIsPresented) { + if let selectedTerminalArtifact { + ChatArtifactViewerDestination( + path: selectedTerminalArtifact.path, + scope: selectedTerminalArtifact.usesSessionAuthorization ? .chat : .terminal + ) { + self.selectedTerminalArtifact = nil + } + .environment( + \.chatArtifactLoader, + artifactLoader(for: selectedTerminalArtifact) ) - ) + } } #else .background(store.activeTerminalTheme.terminalBackgroundColor) @@ -289,6 +297,15 @@ struct WorkspaceDetailView: View { } #if os(iOS) + private var terminalArtifactIsPresented: Binding { + Binding( + get: { selectedTerminalArtifact != nil }, + set: { isPresented in + if !isPresented { selectedTerminalArtifact = nil } + } + ) + } + func terminalArtifactLoader(workspaceID: String, surfaceID: String) -> ChatArtifactLoader { guard let source = store.makeChatEventSource() else { return .unsupported(cache: terminalArtifactThumbnailCache) @@ -297,6 +314,7 @@ struct WorkspaceDetailView: View { terminalWorkspaceID: workspaceID, terminalSurfaceID: surfaceID, supportsArtifacts: store.supportsTerminalArtifacts, + supportsDirectoryBrowsing: store.supportsTerminalArtifactList, cache: terminalArtifactThumbnailCache, stat: { path in try await source.terminalArtifactStat( @@ -313,6 +331,14 @@ struct WorkspaceDetailView: View { progress: progress ) }, + stream: { path, onChunk in + try await source.terminalArtifactFetch( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path, + onChunk: onChunk + ) + }, thumbnail: { path, maxDimension in try await source.terminalArtifactThumbnail( workspaceID: workspaceID, @@ -320,9 +346,34 @@ struct WorkspaceDetailView: View { path: path, maxDimension: maxDimension ) + }, + list: { path in + try await source.terminalArtifactList( + workspaceID: workspaceID, + surfaceID: surfaceID, + path: path + ) } ) } + + private func artifactLoader(for selection: TerminalArtifactSelection) -> ChatArtifactLoader { + guard let sessionID = selection.sessionID else { + return terminalArtifactLoader( + workspaceID: selection.workspaceID, + surfaceID: selection.surfaceID + ) + } + guard store.supportsChatArtifacts, + let source = store.makeChatEventSource() else { + return .unsupported(cache: terminalArtifactThumbnailCache) + } + return ChatArtifactLoader( + source: source, + sessionID: sessionID, + cache: terminalArtifactThumbnailCache + ) + } #endif @ViewBuilder diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 1b6bd15a8d64..0ac88ba7c827 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -42,6 +42,38 @@ import Testing #expect(MobileDisplaySettings(defaults: defaults).showAltScreenNotice) } + @Test func showMissingFilesDefaultsToFalseWithoutAWrite() throws { + let defaults = try makeDefaults("showMissingFilesDefaults") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(!settings.showMissingFiles) + #expect(defaults.object(forKey: "cmux.mobile.showMissingFiles") == nil) + } + + @Test func showMissingFilesPersistsAcrossInstances() throws { + let defaults = try makeDefaults("showMissingFilesPersists") + let settings = MobileDisplaySettings(defaults: defaults) + settings.showMissingFiles = true + #expect(MobileDisplaySettings(defaults: defaults).showMissingFiles) + settings.showMissingFiles = false + #expect(!MobileDisplaySettings(defaults: defaults).showMissingFiles) + } + + @Test func terminalFilesChipDefaultsToFalseWithoutAWrite() throws { + let defaults = try makeDefaults("terminalFilesChipDefaults") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(!settings.terminalFilesChipEnabled) + #expect(defaults.object(forKey: "cmux.mobile.terminalFilesChipEnabled") == nil) + } + + @Test func terminalFilesChipPersistsAcrossInstances() throws { + let defaults = try makeDefaults("terminalFilesChipPersists") + let settings = MobileDisplaySettings(defaults: defaults) + settings.terminalFilesChipEnabled = true + #expect(MobileDisplaySettings(defaults: defaults).terminalFilesChipEnabled) + settings.terminalFilesChipEnabled = false + #expect(!MobileDisplaySettings(defaults: defaults).terminalFilesChipEnabled) + } + @Test func previewLineCountPersistsAcrossInstances() throws { let defaults = try makeDefaults("persists") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChildCountFormatterTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChildCountFormatterTests.swift new file mode 100644 index 000000000000..5f4ffc895af1 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChildCountFormatterTests.swift @@ -0,0 +1,26 @@ +import Foundation +import Testing + +@testable import CmuxMobileShellUI + +@Suite("Terminal artifact child-count formatting") +struct TerminalArtifactChildCountFormatterTests { + @Test("resolves English inflection markup into singular and plural text") + func englishInflection() { + let formatter = TerminalArtifactChildCountFormatter(locale: Locale(identifier: "en")) + + #expect(formatter.string(count: 1, isCapped: false) == "1 item") + #expect(formatter.string(count: 11, isCapped: false) == "11 items") + #expect(!formatter.string(count: 11, isCapped: false).contains("inflect")) + #expect(formatter.string(count: 500, isCapped: true) == "500+ items") + } + + @Test("uses the Japanese localized count without English inflection markup") + func japaneseCount() { + let formatter = TerminalArtifactChildCountFormatter(locale: Locale(identifier: "ja")) + + #expect(formatter.string(count: 1, isCapped: false) == "1項目") + #expect(formatter.string(count: 11, isCapped: false) == "11項目") + #expect(formatter.string(count: 500, isCapped: true) == "500項目以上") + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift index 914033c07e58..7fafbd6225d5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipCountStateTests.swift @@ -21,7 +21,8 @@ struct TerminalArtifactChipCountStateTests { let completion = state.complete( nilTotalRequest, sessionTotal: nil, - currentSurfaceGeneration: 2 + currentSurfaceGeneration: 2, + freshestLocalCount: 5 ) #expect(completion.outcome == .reported(.init(count: 5, surfaceGeneration: 2))) @@ -33,7 +34,8 @@ struct TerminalArtifactChipCountStateTests { let zeroCompletion = state.complete( zeroRequest, sessionTotal: 0, - currentSurfaceGeneration: 3 + currentSurfaceGeneration: 3, + freshestLocalCount: 6 ) #expect(zeroCompletion.outcome == .reported(.init(count: 6, surfaceGeneration: 3))) } @@ -50,7 +52,8 @@ struct TerminalArtifactChipCountStateTests { #expect(resetState.complete( resetRequest, sessionTotal: 20, - currentSurfaceGeneration: 10 + currentSurfaceGeneration: 10, + freshestLocalCount: 2 ) == .stale) var surfaceState = TerminalArtifactChipCountState() @@ -62,7 +65,8 @@ struct TerminalArtifactChipCountStateTests { let completion = surfaceState.complete( surfaceRequest, sessionTotal: 30, - currentSurfaceGeneration: 12 + currentSurfaceGeneration: 12, + freshestLocalCount: 7 ) #expect(completion.outcome == .droppedForSurfaceGenerationMismatch) #expect(completion.nextRequest?.surfaceGeneration == 12) @@ -80,16 +84,18 @@ struct TerminalArtifactChipCountStateTests { let dropped = state.complete( request, sessionTotal: 30, - currentSurfaceGeneration: 12 + currentSurfaceGeneration: 12, + freshestLocalCount: 8 ) let rearmed = try #require(dropped.nextRequest) - #expect(rearmed.localCount == 3) + #expect(rearmed.localCount == 8) #expect(rearmed.surfaceGeneration == 12) let reported = state.complete( rearmed, sessionTotal: 30, - currentSurfaceGeneration: 12 + currentSurfaceGeneration: 12, + freshestLocalCount: 8 ) #expect(reported.outcome == .reported(.init(count: 30, surfaceGeneration: 12))) #expect(reported.nextRequest == nil) @@ -108,7 +114,8 @@ struct TerminalArtifactChipCountStateTests { let completion = state.complete( request, sessionTotal: 40, - currentSurfaceGeneration: UInt64(20 + offset) + currentSurfaceGeneration: UInt64(20 + offset), + freshestLocalCount: 4 + offset ) request = try #require(completion.nextRequest) } @@ -116,7 +123,8 @@ struct TerminalArtifactChipCountStateTests { let bounded = state.complete( request, sessionTotal: 40, - currentSurfaceGeneration: 100 + currentSurfaceGeneration: 100, + freshestLocalCount: 100 ) #expect(bounded.outcome == .droppedForSurfaceGenerationMismatch) #expect(bounded.nextRequest == nil) @@ -140,12 +148,14 @@ struct TerminalArtifactChipCountStateTests { #expect(state.complete( stale, sessionTotal: 10, - currentSurfaceGeneration: 31 + currentSurfaceGeneration: 31, + freshestLocalCount: 2 ) == .stale) #expect(state.complete( current, sessionTotal: 20, - currentSurfaceGeneration: 31 + currentSurfaceGeneration: 31, + freshestLocalCount: 2 ).outcome == .reported(.init(count: 20, surfaceGeneration: 31))) } @@ -171,7 +181,8 @@ struct TerminalArtifactChipCountStateTests { let completion = state.complete( first, sessionTotal: 10, - currentSurfaceGeneration: 22 + currentSurfaceGeneration: 22, + freshestLocalCount: 3 ) #expect(completion.outcome == .droppedForSurfaceGenerationMismatch) #expect(completion.nextRequest?.localCount == 3) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipFeatureGateTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipFeatureGateTests.swift new file mode 100644 index 000000000000..954e7b44ca33 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/TerminalArtifactChipFeatureGateTests.swift @@ -0,0 +1,44 @@ +import Testing + +@testable import CmuxMobileShellUI + +@Suite("Terminal artifact chip feature gate") +struct TerminalArtifactChipFeatureGateTests { + @Test("does not invoke the count scan when the preference is off") + @MainActor + func skipsScanWhenPreferenceIsOff() async { + let gate = TerminalArtifactChipFeatureGate( + artifactsAvailable: true, + preferenceEnabled: false + ) + var scanInvocationCount = 0 + + let result: Int? = await gate.performScan { + scanInvocationCount += 1 + return 7 + } + + #expect(!gate.isEnabled) + #expect(scanInvocationCount == 0) + #expect(result == nil) + } + + @Test("runs the count scan only when capability and preference are enabled") + @MainActor + func scansWhenFullyEnabled() async { + let gate = TerminalArtifactChipFeatureGate( + artifactsAvailable: true, + preferenceEnabled: true + ) + var scanInvocationCount = 0 + + let result: Int? = await gate.performScan { + scanInvocationCount += 1 + return 7 + } + + #expect(gate.isEnabled) + #expect(scanInvocationCount == 1) + #expect(result == 7) + } +} diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceTapDisposition.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceTapDisposition.swift new file mode 100644 index 000000000000..9db795eafeda --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceTapDisposition.swift @@ -0,0 +1,13 @@ +#if canImport(UIKit) +/// Tells the terminal surface whether a completed tap should claim keyboard focus. +public enum GhosttySurfaceTapDisposition: Sendable { + /// The host opened an artifact for the tapped path; terminal focus must remain unchanged. + case openedArtifact + /// The tap belongs to the terminal and should raise or restore its input focus. + case focusTerminal + + var shouldFocusTerminal: Bool { + self == .focusTerminal + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 5647838ad498..ec1e6ea5839f 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1590,17 +1590,26 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { setChromeHidden(false) } let cell = scrollCell(at: gesture.location(in: self)) - delegate?.ghosttySurfaceView(self, didTapAtCol: cell.col, row: cell.row) - // A tap inside the composer band is excluded by the gesture recognizer - // (`gestureRecognizer(_:shouldReceive:)`), so any tap reaching here is a - // deliberate terminal tap. Only a reveal-from-hide with the composer still - // presented re-focuses the composer; every other terminal tap focuses the - // terminal proxy as before. - if wasHidden, composerActive { - delegate?.ghosttySurfaceViewDidRequestComposerFocus(self) - focusMountedComposerField() - } else { - focusInput() + Task { @MainActor [weak self] in + guard let self else { return } + let disposition = await self.delegate?.ghosttySurfaceView( + self, + didTapAtCol: cell.col, + row: cell.row + ) ?? .focusTerminal + guard disposition.shouldFocusTerminal else { return } + + // A tap inside the composer band is excluded by the gesture recognizer + // (`gestureRecognizer(_:shouldReceive:)`), so any tap reaching here is a + // deliberate terminal tap. Only a reveal-from-hide with the composer still + // presented re-focuses the composer; every other terminal tap focuses the + // terminal proxy as before. Artifact taps never enter this focus path. + if wasHidden, self.composerActive { + self.delegate?.ghosttySurfaceViewDidRequestComposerFocus(self) + self.focusMountedComposerField() + } else { + self.focusInput() + } } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift index 85db9ca87998..1698a62ef94d 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceViewDelegate.swift @@ -28,7 +28,11 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { /// cell, so TUIs with mouse reporting (lazygit/htop/fzf) receive the click. /// The Mac's libghostty self-gates: a normal screen treats it as a harmless /// empty selection. Optional. - func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didTapAtCol col: Int, + row: Int + ) async -> GhosttySurfaceTapDisposition /// The user tapped the "customize" button at the end of the input-accessory /// bar; the host should present the toolbar shortcuts editor. Optional. func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) @@ -77,8 +81,14 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { public extension GhosttySurfaceViewDelegate { /// Default no-op so hosts without remote scroll forwarding can ignore it. func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didScrollLines lines: Double, atCol col: Int, row: Int) {} - /// Default no-op so hosts without remote click forwarding can ignore it. - func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) {} + /// Default terminal disposition so hosts without remote click forwarding retain input focus. + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didTapAtCol col: Int, + row: Int + ) async -> GhosttySurfaceTapDisposition { + .focusTerminal + } /// Default no-op so hosts without a toolbar editor can ignore the request. func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) {} /// Default no-op so hosts without terminal artifacts can ignore the request. diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceTapDispositionTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceTapDispositionTests.swift new file mode 100644 index 000000000000..9086f5091b3a --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceTapDispositionTests.swift @@ -0,0 +1,12 @@ +import Testing + +@testable import CmuxMobileTerminal + +@Suite("Terminal surface tap disposition") +struct GhosttySurfaceTapDispositionTests { + @Test("only terminal taps claim input focus") + func inputFocus() { + #expect(GhosttySurfaceTapDisposition.focusTerminal.shouldFocusTerminal) + #expect(!GhosttySurfaceTapDisposition.openedArtifact.shouldFocusTerminal) + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift index c5ecc170589f..ff939bdd5625 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/MobileCatalogSection.swift @@ -2,6 +2,13 @@ import Foundation /// Mobile integration settings for pairing and syncing with cmux on iOS. public struct MobileCatalogSection: SettingCatalogSection { + /// Folder paths that iOS may access after a chat or terminal references a directory. + public let artifactFolderAccess = DefaultsKey( + id: "mobile.artifactFolderAccess", + defaultValue: .subtree, + userDefaultsKey: "mobile.artifactFolderAccess" + ) + /// Mac-side iOS pairing host. Release defaults OFF so macOS never asks for /// Local Network permission until the user opts in from Settings. DEBUG /// (dev) builds default ON so a dev Mac advertises its attach route without a diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MobileArtifactFolderAccess.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MobileArtifactFolderAccess.swift new file mode 100644 index 000000000000..f4f39e83f5c9 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/MobileArtifactFolderAccess.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Mac-side authorization policy for folders browsed from cmux on iOS. +public enum MobileArtifactFolderAccess: String, CaseIterable, Sendable, SettingCodable { + /// Allow referenced directories and every canonical descendant. + case subtree + /// Preserve legacy access: immediate children for file operations and no + /// recursive directory listing. + case oneLevel +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/MobileArtifactFolderAccessTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/MobileArtifactFolderAccessTests.swift new file mode 100644 index 000000000000..f7415a2df2f7 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/MobileArtifactFolderAccessTests.swift @@ -0,0 +1,17 @@ +import Testing + +@testable import CmuxSettings + +@Suite("Mobile artifact folder access") +struct MobileArtifactFolderAccessTests { + @Test("defaults to subtree access") + func defaultValue() { + #expect(SettingCatalog().mobile.artifactFolderAccess.defaultValue == .subtree) + } + + @Test("one-level value round-trips through settings storage") + func oneLevelRoundTrip() { + let encoded = MobileArtifactFolderAccess.oneLevel.encodeForUserDefaults() + #expect(MobileArtifactFolderAccess.decodeFromUserDefaults(encoded) == .oneLevel) + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift index dfb2f4985e0a..05828e11ba5f 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/SettingCatalogTests.swift @@ -92,6 +92,7 @@ struct SettingCatalogTests { #expect(ids.contains("paneBorderColor")) #expect(ids.contains("activePaneBorderColor")) #expect(ids.contains("mobile.iOSPairingHost.enabled")) + #expect(ids.contains("mobile.artifactFolderAccess")) #expect(ids.contains("automation.socketControlMode")) #expect(ids.contains("automation.socketPassword")) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index d884568ecbe6..86eae45c6c8e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -201,6 +201,14 @@ extension Array where Element == CuratedSettingEntry { .init(section: .mobile, id: "iOSPairingHost", title: "iOS Pairing", synonyms: "ios iphone ipad mobile pairing local network permission sync"), .init(section: .mobile, id: "iOSPairingPort", title: String(localized: "settings.mobile.port", defaultValue: "Pairing Port"), synonyms: "mobile.iOSPairingHost.port ios iphone mobile pairing port tcp listener firewall conflict"), .init(section: .mobile, id: "iOSPairingDisplayName", title: String(localized: "settings.mobile.displayName", defaultValue: "Display Name"), synonyms: "mobile.iOSPairingHost.displayName ios iphone mobile pairing display name mac hostname device label"), + .init( + section: .mobile, + id: "artifactFolderAccess", + title: String(localized: "settings.mobile.artifactFolderAccess", defaultValue: "Folder Access"), + detailText: String(localized: "settings.mobile.artifactFolderAccess.subtitleSubtree", defaultValue: "Lets iOS browse any item inside a folder referenced by chat or visible in a terminal."), + paths: ["mobile.artifactFolderAccess"], + synonyms: "ios iphone ipad mobile files folders directory subtree one level authorization security" + ), // Custom Sidebars .init(section: .customSidebars, id: "enabled", title: String(localized: "settings.customSidebars.enabled", defaultValue: "Show Custom Sidebars"), synonyms: "custom sidebars enable show vibe swift json interpreted picker beta"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift index 744dce3c079f..1e9c3fbccab5 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/MobileSection.swift @@ -11,6 +11,7 @@ public struct MobileSection: View { @State private var iOSPairingHost: DefaultsValueModel @State private var port: DefaultsValueModel @State private var displayName: DefaultsValueModel + @State private var artifactFolderAccess: DefaultsValueModel @State private var status: MobilePairingStatusModel /// The user's in-progress port edit, or `nil` when the field should track @@ -45,6 +46,10 @@ public struct MobileSection: View { _iOSPairingHost = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingHost)) _port = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingPort)) _displayName = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.mobile.iOSPairingDisplayName)) + _artifactFolderAccess = State(initialValue: DefaultsValueModel( + store: defaultsStore, + key: catalog.mobile.artifactFolderAccess + )) _status = State(initialValue: MobilePairingStatusModel(hostActions: hostActions)) self.hostActions = hostActions } @@ -78,6 +83,8 @@ public struct MobileSection: View { boundPortStatusRow SettingsCardDivider() displayNameRow + SettingsCardDivider() + artifactFolderAccessRow if iOSPairingHost.current { SettingsCardDivider() diagnostics @@ -96,6 +103,7 @@ public struct MobileSection: View { iOSPairingHost, port, displayName, + artifactFolderAccess, status, ] models.forEach { $0.startObserving() } @@ -278,6 +286,52 @@ public struct MobileSection: View { } } + @ViewBuilder + private var artifactFolderAccessRow: some View { + SettingsCardRow( + configurationReview: .json("mobile.artifactFolderAccess"), + String(localized: "settings.mobile.artifactFolderAccess", defaultValue: "Folder Access"), + subtitle: artifactFolderAccessSubtitle + ) { + Picker( + "", + selection: Binding( + get: { artifactFolderAccess.current }, + set: { artifactFolderAccess.set($0) } + ) + ) { + Text(String( + localized: "settings.mobile.artifactFolderAccess.subtree", + defaultValue: "Entire Subtree" + )) + .tag(MobileArtifactFolderAccess.subtree) + Text(String( + localized: "settings.mobile.artifactFolderAccess.oneLevel", + defaultValue: "One Level" + )) + .tag(MobileArtifactFolderAccess.oneLevel) + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("SettingsMobileArtifactFolderAccessPicker") + } + } + + private var artifactFolderAccessSubtitle: String { + switch artifactFolderAccess.current { + case .subtree: + String( + localized: "settings.mobile.artifactFolderAccess.subtitleSubtree", + defaultValue: "Lets iOS browse any item inside a folder referenced by chat or visible in a terminal." + ) + case .oneLevel: + String( + localized: "settings.mobile.artifactFolderAccess.subtitleOneLevel", + defaultValue: "Limits iOS to immediate children of referenced or visible folders." + ) + } + } + /// Read-only connection count and the reachable routes the phone can use. @ViewBuilder private var diagnostics: some View { diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 386f6527d8a0..23d381b34346 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -81,6 +81,7 @@ struct SettingsRowAnchorResolutionTests { "canvas.snappingEnabled", "customSidebars.renderer", "fileEditor.wordWrap", + "mobile.artifactFolderAccess", "notifications.agentIdleReminder", "notifications.agentPermissionPrompt", "notifications.agentTurnComplete", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7bce34947f82..fafb550c3325 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -163823,6 +163823,91 @@ } } }, + "settings.mobile.artifactFolderAccess": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder Access" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダアクセス" + } + } + } + }, + "settings.mobile.artifactFolderAccess.oneLevel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "One Level" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "1 階層" + } + } + } + }, + "settings.mobile.artifactFolderAccess.subtree": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Entire Subtree" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サブツリー全体" + } + } + } + }, + "settings.mobile.artifactFolderAccess.subtitleOneLevel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Limits iOS to immediate children of referenced or visible folders." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iOS からのアクセスを、参照された、または表示中のフォルダの直下の項目に制限します。" + } + } + } + }, + "settings.mobile.artifactFolderAccess.subtitleSubtree": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Lets iOS browse any item inside a folder referenced by chat or visible in a terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャットで参照された、またはターミナルに表示されたフォルダ内のすべての項目を iOS から参照できるようにします。" + } + } + } + }, "settings.mobile.connections": { "extractionState": "manual", "localizations": { @@ -176528,6 +176613,23 @@ } } }, + "settings.search.alias.setting.mobile.artifactFolderAccess": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "mobile ios iphone files folders directory subtree one level authorization security" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "モバイル ios iphone ファイル フォルダ ディレクトリ サブツリー 1 階層 認証 セキュリティ" + } + } + } + }, "settings.search.alias.setting.mobile.iOSPairingDisplayName": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 1303e5e2e5c4..daf7d15c0dd3 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -501,6 +501,7 @@ extension CmuxSettingsFileStore { "browser.insecureHttpHostsAllowedInEmbeddedBrowser", "browser.showImportHintOnBlankTabs", "browser.reactGrabVersion", + "mobile.artifactFolderAccess", "markdown.fontSize", "markdown.fontFamily", "markdown.maxWidth", diff --git a/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift b/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift index 8bcbd7bf1aa5..4fe6fc2e0be9 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+SectionParsers.swift @@ -1,7 +1,7 @@ import CmuxSettings import Foundation -/// Settings-file section parsers for file editor, file explorer, and sidebar workspace-todo options, extracted from `KeyboardShortcutSettingsFileStore.swift`, which sits at its file-length budget. +/// Settings-file section parsers for file editor, file explorer, markdown, mobile, and sidebar workspace-todo options, extracted from `KeyboardShortcutSettingsFileStore.swift`, which sits at its file-length budget. extension CmuxSettingsFileStore { func parseFileEditorSection( _ section: [String: Any], @@ -64,4 +64,55 @@ extension CmuxSettingsFileStore { logInvalid("sidebar.beta.workspaceTodos", sourcePath: sourcePath) } } + + func parseMarkdownSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + // Accept numeric doubles (e.g. 15 or 15.0) and round to integer points, + // matching the integer `markdown.fontSize` catalog/UI representation. + if let value = jsonDouble(section["fontSize"]) { + if value >= MarkdownFontSizeSettings.minimumPointSize, + value <= MarkdownFontSizeSettings.maximumPointSize { + snapshot.managedUserDefaults[MarkdownFontSizeSettings.key] = .int(Int(value.rounded())) + } else { + logInvalid("markdown.fontSize", sourcePath: sourcePath) + } + } else if section.keys.contains("fontSize") { + logInvalid("markdown.fontSize", sourcePath: sourcePath) + } + + if let value = jsonString(section["fontFamily"]) { + snapshot.managedUserDefaults[MarkdownFontFamily.key] = .string(MarkdownFontFamily.normalized(value)) + } else if section.keys.contains("fontFamily") { + logInvalid("markdown.fontFamily", sourcePath: sourcePath) + } + + if let value = jsonDouble(section["maxWidth"]) { + if value >= MarkdownMaxWidthSettings.minimumCSSPixels, + value <= MarkdownMaxWidthSettings.maximumCSSPixels { + snapshot.managedUserDefaults[MarkdownMaxWidthSettings.key] = .int(Int(value.rounded())) + } else { + logInvalid("markdown.maxWidth", sourcePath: sourcePath) + } + } else if section.keys.contains("maxWidth") { + logInvalid("markdown.maxWidth", sourcePath: sourcePath) + } + } + + func parseMobileSection( + _ section: [String: Any], + sourcePath: String, + snapshot: inout ResolvedSettingsSnapshot + ) { + guard section.keys.contains("artifactFolderAccess") else { return } + guard let raw = jsonString(section["artifactFolderAccess"]), + let value = MobileArtifactFolderAccess(rawValue: raw) else { + logInvalid("mobile.artifactFolderAccess", sourcePath: sourcePath) + return + } + let key = SettingCatalog().mobile.artifactFolderAccess + snapshot.managedUserDefaults[key.userDefaultsKey] = .string(value.rawValue) + } } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 2667e1d480eb..90dd5e2daf7e 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -213,6 +213,11 @@ extension CmuxSettingsFileStore { "reactGrabVersion": ReactGrabSettings.defaultVersion, ], ], + [ + "mobile": [ + "artifactFolderAccess": SettingCatalog().mobile.artifactFolderAccess.defaultValue.rawValue, + ], + ], [ "markdown": [ "fontSize": Int(MarkdownFontSizeSettings.defaultPointSize), diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index bdc1482b6111..ec096d8edf9b 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -418,6 +418,9 @@ final class CmuxSettingsFileStore { if let browserSection = root["browser"] as? [String: Any] { parseBrowserSection(browserSection, sourcePath: sourcePath, snapshot: &snapshot) } + if let mobileSection = root["mobile"] as? [String: Any] { + parseMobileSection(mobileSection, sourcePath: sourcePath, snapshot: &snapshot) + } if let markdownSection = root["markdown"] as? [String: Any] { parseMarkdownSection(markdownSection, sourcePath: sourcePath, snapshot: &snapshot) } @@ -696,42 +699,6 @@ final class CmuxSettingsFileStore { } } - private func parseMarkdownSection( - _ section: [String: Any], - sourcePath: String, - snapshot: inout ResolvedSettingsSnapshot - ) { - // Accept numeric doubles (e.g. 15 or 15.0) and round to integer points, - // matching the integer `markdown.fontSize` catalog/UI representation. - if let value = jsonDouble(section["fontSize"]) { - if value >= MarkdownFontSizeSettings.minimumPointSize, - value <= MarkdownFontSizeSettings.maximumPointSize { - snapshot.managedUserDefaults[MarkdownFontSizeSettings.key] = .int(Int(value.rounded())) - } else { - logInvalid("markdown.fontSize", sourcePath: sourcePath) - } - } else if section.keys.contains("fontSize") { - logInvalid("markdown.fontSize", sourcePath: sourcePath) - } - - if let value = jsonString(section["fontFamily"]) { - snapshot.managedUserDefaults[MarkdownFontFamily.key] = .string(MarkdownFontFamily.normalized(value)) - } else if section.keys.contains("fontFamily") { - logInvalid("markdown.fontFamily", sourcePath: sourcePath) - } - - if let value = jsonDouble(section["maxWidth"]) { - if value >= MarkdownMaxWidthSettings.minimumCSSPixels, - value <= MarkdownMaxWidthSettings.maximumCSSPixels { - snapshot.managedUserDefaults[MarkdownMaxWidthSettings.key] = .int(Int(value.rounded())) - } else { - logInvalid("markdown.maxWidth", sourcePath: sourcePath) - } - } else if section.keys.contains("maxWidth") { - logInvalid("markdown.maxWidth", sourcePath: sourcePath) - } - } - private func parseSidebarSection( _ section: [String: Any], sourcePath: String, @@ -1838,7 +1805,7 @@ final class CmuxSettingsFileStore { return number.intValue } - private func jsonDouble(_ rawValue: Any?) -> Double? { + func jsonDouble(_ rawValue: Any?) -> Double? { guard let number = rawValue as? NSNumber else { return nil } guard CFGetTypeID(number) != CFBooleanGetTypeID() else { return nil } return number.doubleValue diff --git a/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift b/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift index d1d885c119fb..ac5b645bce4e 100644 --- a/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift +++ b/Sources/Mobile/AgentChat/AgentChatArtifactGalleryBuilder.swift @@ -1,90 +1,3 @@ import CmuxAgentChat -import Foundation -/// Builds stat-enriched, append-only pages from one transcript index snapshot. -struct AgentChatArtifactGalleryBuilder: Sendable { - /// Creates a gallery page builder. - init() {} - - /// Builds one sectioned or flat search page. - func page( - sessionID: String, - items: [ChatArtifactIndexedReference], - generation: String, - cursor: ChatArtifactGalleryCursor?, - pageSize: Int, - query: String? - ) -> ChatArtifactGalleryPage { - let ordering = ChatArtifactGalleryOrdering() - let normalizedQuery = query?.trimmingCharacters(in: .whitespacesAndNewlines) - let isSearch = normalizedQuery?.isEmpty == false - let candidates: [ChatArtifactIndexedReference] - if let normalizedQuery, !normalizedQuery.isEmpty { - candidates = ordering.search(items, query: normalizedQuery) - } else { - candidates = ordering.sorted(items.filter { $0.provenance == .referenced }) - } - let remaining = ordering.items(candidates, strictlyAfter: cursor) - let pageReferences = Array(remaining.prefix(pageSize)) - let nextCursor: String? - if remaining.count > pageReferences.count, let last = pageReferences.last { - nextCursor = try? ChatArtifactGalleryCursor( - generation: generation, - seq: last.lastReferencedSeq, - path: last.path - ).token() - } else { - nextCursor = nil - } - - let includeCompleteSections = cursor == nil && !isSearch - let created = includeCompleteSections - ? statItems(ordering.sorted(items.filter { $0.provenance == .created })) - : [] - let attached = includeCompleteSections - ? statItems(ordering.sorted(items.filter { $0.provenance == .attached })) - : [] - return ChatArtifactGalleryPage( - sessionID: sessionID, - created: created, - attached: attached, - referenced: statItems(pageReferences), - referencedTotal: candidates.count, - nextCursor: nextCursor, - generation: generation - ) - } - - private func statItems( - _ references: [ChatArtifactIndexedReference] - ) -> [ChatArtifactGalleryItem] { - let reader = ArtifactByteReader() - return references.map { reference in - do { - let stat = try reader.stat(path: reference.path) - return ChatArtifactGalleryItem( - path: reference.path, - kind: stat.kind, - displayName: URL(fileURLWithPath: reference.path).lastPathComponent, - size: stat.size, - modifiedAt: stat.modifiedAt, - exists: stat.exists, - provenance: reference.provenance - ) - } catch { - #if DEBUG - cmuxDebugLog( - "mobile.chat.artifact.gallery unavailable reason=stat-failed path=\(reference.path)" - ) - #endif - return ChatArtifactGalleryItem( - path: reference.path, - kind: reader.kind(path: reference.path, isDirectory: false), - displayName: URL(fileURLWithPath: reference.path).lastPathComponent, - exists: false, - provenance: reference.provenance - ) - } - } - } -} +typealias AgentChatArtifactGalleryBuilder = ChatArtifactGalleryBuilder diff --git a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift index ce789a171a51..23d0d963288f 100644 --- a/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift +++ b/Sources/Mobile/AgentChat/AgentChatArtifactIndex.swift @@ -5,7 +5,6 @@ import Foundation actor AgentChatArtifactIndex { struct Snapshot: Sendable { let referencedPaths: Set - let scope: ChatArtifactScope let artifacts: [ChatArtifactIndexedReference] let generation: String } @@ -37,7 +36,7 @@ actor AgentChatArtifactIndex { let snapshot: Snapshot } - private var cacheBySessionID: [String: CacheEntry] = [:] + private var cacheBySessionID = ChatArtifactLRUCache(capacity: 8) func snapshot( sessionID: String, @@ -46,7 +45,7 @@ actor AgentChatArtifactIndex { workingDirectory: String? ) async throws -> Snapshot { let key = try Self.cacheKey(transcriptPath: transcriptPath, workingDirectory: workingDirectory) - if let cached = cacheBySessionID[sessionID], cached.key == key { + if let cached = cacheBySessionID.value(forKey: sessionID), cached.key == key { return cached.snapshot } let snapshot = try Self.buildSnapshot( @@ -55,7 +54,7 @@ actor AgentChatArtifactIndex { workingDirectory: workingDirectory, generation: key.generation ) - cacheBySessionID[sessionID] = CacheEntry(key: key, snapshot: snapshot) + cacheBySessionID.insert(CacheEntry(key: key, snapshot: snapshot), forKey: sessionID) return snapshot } @@ -65,7 +64,8 @@ actor AgentChatArtifactIndex { transcriptPath: String, workingDirectory: String?, requestedPath: String, - operation: Operation + operation: Operation, + directoryAccessMode: ChatArtifactScope.DirectoryAccessMode ) async throws -> CanonicalPathResult { let snapshot = try await snapshot( sessionID: sessionID, @@ -78,11 +78,16 @@ actor AgentChatArtifactIndex { return .canonicalizationFailed } let canonicalPath: String? + let scope = ChatArtifactScope( + referencedPaths: snapshot.referencedPaths, + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) switch operation { case .file: - canonicalPath = snapshot.scope.canonicalFilePath(for: requestedPath) + canonicalPath = scope.canonicalFilePath(for: requestedPath) case .list: - canonicalPath = snapshot.scope.canonicalDirectoryListPath(for: requestedPath) + canonicalPath = scope.canonicalDirectoryListPath(for: requestedPath) } return canonicalPath.map(CanonicalPathResult.success) ?? .notInSet } @@ -123,10 +128,6 @@ actor AgentChatArtifactIndex { let referencedPaths = Set(artifacts.map(\.path)) return Snapshot( referencedPaths: referencedPaths, - scope: ChatArtifactScope( - referencedPaths: referencedPaths, - resolver: ChatArtifactScope.FoundationResolver() - ), artifacts: artifacts, generation: generation ) diff --git a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift index 568bc73bdaf8..7736b3e2b862 100644 --- a/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift +++ b/Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift @@ -7,6 +7,7 @@ import Foundation @MainActor final class AgentChatSessionRegistry { private var records: [String: AgentChatSessionRecord] = [:] + private var sessionSurfaceIndex = ChatSessionSurfaceIndex() private var liveSessionIDBySurfaceID: [String: String] = [:] private var liveClaudeSessionIDsBySurfaceID: [String: Set] = [:] private let hookStore: AgentChatHookSessionStore @@ -46,9 +47,22 @@ final class AgentChatSessionRegistry { /// Creates a registry. /// - /// - Parameter hookStore: Reader for the per-agent hook session stores. - init(hookStore: AgentChatHookSessionStore = AgentChatHookSessionStore()) { + /// - Parameters: + /// - hookStore: Reader for the per-agent hook session stores. + /// - restoredRecords: Records restored before live observation begins. + init( + hookStore: AgentChatHookSessionStore = AgentChatHookSessionStore(), + restoredRecords: [AgentChatSessionRecord] = [] + ) { self.hookStore = hookStore + for record in restoredRecords { + records[record.sessionID] = record + versionBySessionID[record.sessionID] = record.version + } + rebuildSessionIndexes() + for record in records.values { + syncProcessExitWatch(for: record) + } } /// All known sessions, optionally restricted to one workspace, most @@ -138,10 +152,7 @@ final class AgentChatSessionRegistry { } stampLifecycleTransition(previous: nil, current: &record, at: session.sampledAt) stampVersion(&record) - records[targetSessionID] = record - syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: nil, current: record) - onRecordChanged?(record, nil) + storeRecord(record, replacing: nil) } else { guard let current = records[targetSessionID] else { continue } if reviveEndedObservedSessionIfNeeded(current: current, observed: session, now: now) { @@ -259,8 +270,7 @@ final class AgentChatSessionRegistry { if let live = liveSession(surfaceID: surfaceID) { return live } - return records.values - .filter { $0.surfaceID == surfaceID } + return indexedRecords(surfaceID: surfaceID) .max { $0.lastActivityAt < $1.lastActivityAt } } @@ -300,7 +310,6 @@ final class AgentChatSessionRegistry { mutate(&record) stampLifecycleTransition(previous: previous, current: &record, at: Date()) stampVersion(&record) - records[sessionID] = record #if DEBUG if previous.state != record.state { cmuxDebugLog( @@ -309,9 +318,7 @@ final class AgentChatSessionRegistry { ) } #endif - syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: previous, current: record) - onRecordChanged?(record, previous) + storeRecord(record, replacing: previous) } #if DEBUG @@ -386,10 +393,7 @@ final class AgentChatSessionRegistry { record.rememberHookStoreSessionID(entry.sessionID) stampLifecycleTransition(previous: nil, current: &record, at: entry.updatedAt ?? Date()) stampVersion(&record) - records[sessionID] = record - syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: nil, current: record) - onRecordChanged?(record, nil) + storeRecord(record, replacing: nil) } } } @@ -474,10 +478,7 @@ final class AgentChatSessionRegistry { record.setHookLifecycleState(Self.nextState(previous: record.state, event: event)) stampLifecycleTransition(previous: previous, current: &record, at: event.receivedAt) stampVersion(&record) - records[sessionID] = record - syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: previous, current: record) - onRecordChanged?(record, previous) + storeRecord(record, replacing: previous) if shouldConsultStore { backfillBindingsFromStore( sessionID: sessionID, @@ -554,18 +555,7 @@ final class AgentChatSessionRegistry { .map(\.sessionID) guard !aliases.isEmpty else { return } for alias in aliases { - guard var record = records.removeValue(forKey: alias) else { continue } - stampVersion(&record) - exitWatchers[alias]?.source.cancel() - exitWatchers[alias] = nil - hookStoreConsultedAt.removeValue(forKey: alias) - updateLiveSessionIndex(previous: record, current: nil) - onRecordRemoved?(record) - } - if let indexed = liveSessionIDBySurfaceID[surfaceID], - aliases.contains(indexed) { - liveSessionIDBySurfaceID.removeValue(forKey: surfaceID) - rebuildLiveSessionIndex(surfaceID: surfaceID) + removeRecord(sessionID: alias) } } @@ -648,10 +638,7 @@ final class AgentChatSessionRegistry { ) stampLifecycleTransition(previous: nil, current: &record, at: now) stampVersion(&record) - records[sessionID] = record - syncProcessExitWatch(for: record) - updateLiveSessionIndex(previous: nil, current: record) - onRecordChanged?(record, nil) + storeRecord(record, replacing: nil) } /// Reads one session's hook-store entry OFF the main actor and applies any @@ -692,10 +679,68 @@ final class AgentChatSessionRegistry { } } - private func updateLiveSessionIndex( + /// Stores one record and reconciles every derived registry structure. + private func storeRecord( + _ record: AgentChatSessionRecord, + replacing previous: AgentChatSessionRecord? + ) { + records[record.sessionID] = record + syncProcessExitWatch(for: record) + updateSessionIndexes(previous: previous, current: record) + onRecordChanged?(record, previous) + } + + /// Removes one record and reconciles every derived registry structure. + private func removeRecord(sessionID: String) { + guard var record = records.removeValue(forKey: sessionID) else { return } + stampVersion(&record) + exitWatchers[sessionID]?.source.cancel() + exitWatchers[sessionID] = nil + hookStoreConsultedAt.removeValue(forKey: sessionID) + updateSessionIndexes(previous: record, current: nil) + onRecordRemoved?(record) + } + + /// Rebuilds all derived indexes after records are restored at initialization. + private func rebuildSessionIndexes() { + sessionSurfaceIndex = ChatSessionSurfaceIndex() + liveSessionIDBySurfaceID.removeAll(keepingCapacity: true) + liveClaudeSessionIDsBySurfaceID.removeAll(keepingCapacity: true) + for record in records.values { + updateSessionIndexes(previous: nil, current: record) + } + } + + /// Resolves indexed records and repairs a missing or stale surface entry. + private func indexedRecords(surfaceID: String) -> [AgentChatSessionRecord] { + let before = sessionSurfaceIndex.sessionIDs(surfaceID: surfaceID) + let sessionIDs = sessionSurfaceIndex.sessionIDs( + surfaceID: surfaceID, + healingFrom: records, + recordSurfaceID: \.surfaceID + ) + #if DEBUG + if before != sessionIDs, !sessionIDs.isEmpty { + cmuxDebugLog( + "agentChat.surfaceIndex divergence surface=\(surfaceID.prefix(8)) " + + "indexed=\(before.count) recovered=\(sessionIDs.count)" + ) + } + #endif + return sessionIDs.compactMap { records[$0] } + } + + private func updateSessionIndexes( previous: AgentChatSessionRecord?, current: AgentChatSessionRecord? ) { + if let sessionID = current?.sessionID ?? previous?.sessionID { + sessionSurfaceIndex.update( + sessionID: sessionID, + previousSurfaceID: previous?.surfaceID, + currentSurfaceID: current?.surfaceID + ) + } updateLiveClaudeSessionIndex(previous: previous, current: current) let previousSurfaceID = Self.liveSurfaceID(previous) let currentSurfaceID = Self.liveSurfaceID(current) @@ -736,7 +781,7 @@ final class AgentChatSessionRegistry { private func rebuildLiveSessionIndex(surfaceID: String?) { guard let surfaceID else { return } - if let newest = records.values + if let newest = indexedRecords(surfaceID: surfaceID) .filter({ $0.surfaceID == surfaceID && $0.state != .ended }) .max(by: { $0.lastActivityAt < $1.lastActivityAt }) { liveSessionIDBySurfaceID[surfaceID] = newest.sessionID diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index 6303d0d6a008..477ea010f6db 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -25,6 +25,7 @@ extension MobileHostService { "terminal.replay.v1", "terminal.viewport.v1", "terminal.artifact.v1", + "terminal.artifact.list.v1", "workspace.actions.v1", "workspace.read_state.v1", "workspace.close.v1", @@ -33,6 +34,7 @@ extension MobileHostService { "workspace.group_create.v1", "workspace.create_in_group.v1", "chat.artifact.v1", + "chat.artifact.folders.v1", "chat.artifact.gallery.v1", "dogfood.v1", // The workspace list carries group sections (group_id per workspace + diff --git a/Sources/Mobile/MobileHostService+TicketAuthorization.swift b/Sources/Mobile/MobileHostService+TicketAuthorization.swift index 3057f37b1b68..f9ca09fbfb8e 100644 --- a/Sources/Mobile/MobileHostService+TicketAuthorization.swift +++ b/Sources/Mobile/MobileHostService+TicketAuthorization.swift @@ -71,7 +71,8 @@ extension MobileHostService { "mobile.terminal.artifact.scan", "mobile.terminal.artifact.stat", "mobile.terminal.artifact.fetch", - "mobile.terminal.artifact.thumbnail": + "mobile.terminal.artifact.thumbnail", + "mobile.terminal.artifact.list": return ticketTerminalAuthorizationError( authorization: authorization, workspaceSelection: workspaceSelection.value, diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index 769f01e0f321..a355034c3e25 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -137,6 +137,7 @@ enum SettingsSearchAliasIndex { "mobile:iOSPairingHost": localized("settings.search.alias.setting.mobile.iOSPairingHost", defaultValue: "ios iphone ipad mobile pairing local network permission sync"), "mobile:iOSPairingPort": localized("settings.search.alias.setting.mobile.iOSPairingPort", defaultValue: "mobile ios iphone pairing port tcp listener firewall conflict bind"), "mobile:iOSPairingDisplayName": localized("settings.search.alias.setting.mobile.iOSPairingDisplayName", defaultValue: "mobile ios iphone pairing display name mac hostname device label"), + "mobile:artifactFolderAccess": localized("settings.search.alias.setting.mobile.artifactFolderAccess", defaultValue: "mobile ios iphone files folders directory subtree one level authorization security"), "networking:relayPreference": localized("settings.search.alias.setting.networking.relayPreference", defaultValue: "iroh relay preference automatic managed selected cmux fleet provider region account"), "networking:customRelays": localized("settings.search.alias.setting.networking.customRelays", defaultValue: "iroh custom relay self hosted server url secret credential device authentication"), "networking:privateNetworks": localized("settings.search.alias.setting.networking.privateNetworks", defaultValue: "private network lan vpn direct peer path local address generic provider automatic"), diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift index 659d7dc50925..3d858b3208b7 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSlotViews.swift @@ -96,7 +96,7 @@ final class SidebarRowPullRequestIconView: NSView { ) // Tint inside the image first: .sourceAtop against the view's // transparent backing draws nothing (no destination pixels). - let tinted = NSImage(size: image.size, flipped: false) { drawRect in + let tinted = NSImage(size: image.size, flipped: false) { [color] drawRect in image.draw(in: drawRect, from: .zero, operation: .sourceOver, fraction: 1) color.set() drawRect.fill(using: .sourceAtop) diff --git a/Sources/TerminalController+MobileChatArtifacts.swift b/Sources/TerminalController+MobileChatArtifacts.swift index b394f5e231fe..f2fbc8520247 100644 --- a/Sources/TerminalController+MobileChatArtifacts.swift +++ b/Sources/TerminalController+MobileChatArtifacts.swift @@ -1,8 +1,10 @@ import CmuxAgentChat +import CmuxSettings import Foundation private enum TerminalControllerChatArtifactIndexProvider { static let shared = AgentChatArtifactIndex() + static let ordering = ChatArtifactGalleryOrderingCache() } extension TerminalController { @@ -41,22 +43,25 @@ extension TerminalController { } let pageSize = min(max(v2Int(params, "page_size") ?? 60, 1), 100) let query = v2RawString(params, "query") + let includeDirectories = v2Bool(params, "include_directories") ?? false + let orderedItems = await TerminalControllerChatArtifactIndexProvider.ordering.ordered( + indexedSession.snapshot.artifacts, + indexID: indexedSession.sessionID, + generation: indexedSession.snapshot.generation + ) let page = await Task.detached(priority: .utility) { AgentChatArtifactGalleryBuilder().page( sessionID: indexedSession.sessionID, items: indexedSession.snapshot.artifacts, + orderedItems: orderedItems, generation: indexedSession.snapshot.generation, cursor: cursor, pageSize: pageSize, - query: query + query: query, + includeDirectories: includeDirectories ) }.value - var payload = ChatArtifactWire.payload(page) ?? [:] - if cursor != nil { - payload.removeValue(forKey: "created") - payload.removeValue(forKey: "attached") - } - return .ok(payload) + return .ok(ChatArtifactWire.payload(page) ?? [:]) } catch { return mobileChatArtifactError(.notFound, path: "") } @@ -244,7 +249,8 @@ extension TerminalController { transcriptPath: transcriptPath, workingDirectory: record.workingDirectory, requestedPath: requestedPath, - operation: operation.indexOperation + operation: operation.indexOperation, + directoryAccessMode: mobileArtifactDirectoryAccessMode() ) switch pathResult { case .success(let canonicalPath): @@ -268,6 +274,22 @@ extension TerminalController { } } + /// Resolves the persisted mobile folder setting into the shared scope policy. + func mobileArtifactDirectoryAccessMode( + defaults: UserDefaults = .standard + ) -> ChatArtifactScope.DirectoryAccessMode { + let key = SettingCatalog().mobile.artifactFolderAccess + let setting = MobileArtifactFolderAccess.decodeFromUserDefaults( + defaults.object(forKey: key.userDefaultsKey) + ) ?? key.defaultValue + switch setting { + case .subtree: + return .subtree + case .oneLevel: + return .oneLevel + } + } + private func debugLogMobileChatArtifactDenial(code: String, reason: String, path: String) { #if DEBUG cmuxDebugLog("mobile.chat.artifact.deny code=\(code) reason=\(reason) path=\(path)") diff --git a/Sources/TerminalController+MobileTerminalArtifacts.swift b/Sources/TerminalController+MobileTerminalArtifacts.swift index 49473e3c3ff7..ac0889128968 100644 --- a/Sources/TerminalController+MobileTerminalArtifacts.swift +++ b/Sources/TerminalController+MobileTerminalArtifacts.swift @@ -33,6 +33,8 @@ extension TerminalController { return await v2MobileTerminalArtifactFetch(params: params) case "mobile.terminal.artifact.thumbnail": return await v2MobileTerminalArtifactThumbnail(params: params) + case "mobile.terminal.artifact.list": + return await v2MobileTerminalArtifactList(params: params) default: return .err(code: "method_not_found", message: "Unknown mobile terminal artifact method", data: nil) } @@ -41,7 +43,8 @@ extension TerminalController { func v2MobileTerminalArtifactScan(params: [String: Any]) async -> V2CallResult { let visibleOnly = v2Bool(params, "visible_only") ?? false let countOnly = v2Bool(params, "count_only") ?? false - let resolution = mobileTerminalArtifactContext( + let includeDirectories = v2Bool(params, "include_directories") ?? false + let resolution = await mobileTerminalArtifactContext( params: params, requiresPath: false, includeScrollback: !visibleOnly, @@ -52,35 +55,40 @@ extension TerminalController { } if countOnly { guard let sessionID = context.sessionID else { - return .ok(TerminalArtifactWire.payload( + return TerminalArtifactWire.result( TerminalArtifactScanResponse(artifacts: []) - ) ?? [:]) + ) } do { guard let indexedSession = try await mobileChatArtifactIndexedSession(sessionID: sessionID) else { - return .ok(TerminalArtifactWire.payload( + return TerminalArtifactWire.result( TerminalArtifactScanResponse(artifacts: [], sessionID: sessionID) - ) ?? [:]) + ) } let response = TerminalArtifactScanResponse.sessionCount( sessionID: indexedSession.sessionID, sessionArtifacts: indexedSession.snapshot.artifacts ) - return .ok(TerminalArtifactWire.payload(response) ?? [:]) + return TerminalArtifactWire.result(response) } catch { - return .ok(TerminalArtifactWire.payload( + return TerminalArtifactWire.result( TerminalArtifactScanResponse(artifacts: [], sessionID: sessionID) - ) ?? [:]) + ) } } let response = await Task.detached(priority: .utility) { - context.scan() + context.scan(includeDirectories: includeDirectories) }.value - return .ok(TerminalArtifactWire.payload(response) ?? [:]) + await terminalArtifactAuthorizationStore.record( + workspaceID: context.workspaceID, + surfaceID: context.surfaceID, + canonicalPaths: Set(response.artifacts.map(\.path)) + ) + return TerminalArtifactWire.result(response) } func v2MobileTerminalArtifactStat(params: [String: Any]) async -> V2CallResult { - let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) guard case .success(let context) = resolution else { return resolution.failureResult } @@ -90,7 +98,7 @@ extension TerminalController { try reader.stat(path: canonicalPath) } }.value - return .ok(TerminalArtifactWire.payload(stat) ?? [:]) + return TerminalArtifactWire.result(stat) } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "stat", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) @@ -104,7 +112,7 @@ extension TerminalController { } func v2MobileTerminalArtifactFetch(params: [String: Any]) async -> V2CallResult { - let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) guard case .success(let context) = resolution else { return resolution.failureResult } @@ -117,7 +125,7 @@ extension TerminalController { try reader.fetch(path: canonicalPath, offset: offset, length: length) } }.value - return .ok(TerminalArtifactWire.payload(chunk) ?? [:]) + return TerminalArtifactWire.result(chunk) } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "fetch", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) @@ -129,7 +137,7 @@ extension TerminalController { } func v2MobileTerminalArtifactThumbnail(params: [String: Any]) async -> V2CallResult { - let resolution = mobileTerminalArtifactContext(params: params, requiresPath: true) + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) guard case .success(let context) = resolution else { return resolution.failureResult } @@ -140,7 +148,7 @@ extension TerminalController { try reader.thumbnail(path: canonicalPath, maxDimension: maxDimension) } }.value - return .ok(TerminalArtifactWire.payload(thumbnail) ?? [:]) + return TerminalArtifactWire.result(thumbnail) } catch TerminalArtifactReadContext.Error.forbidden { debugLogMobileTerminalArtifactDenial(op: "thumbnail", path: context.requestedPath) return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) @@ -151,12 +159,34 @@ extension TerminalController { } } + func v2MobileTerminalArtifactList(params: [String: Any]) async -> V2CallResult { + let resolution = await mobileTerminalArtifactContext(params: params, requiresPath: true) + guard case .success(let context) = resolution else { + return resolution.failureResult + } + do { + let listing = try await Task.detached(priority: .utility) { + try context.authorizedDirectoryList { reader, canonicalPath in + try reader.list(path: canonicalPath) + } + }.value + return TerminalArtifactWire.result(listing) + } catch TerminalArtifactReadContext.Error.forbidden { + debugLogMobileTerminalArtifactDenial(op: "list", path: context.requestedPath) + return mobileTerminalArtifactError(.forbidden, path: context.requestedPath) + } catch ArtifactByteReader.Error.fileNotFound { + return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + } catch { + return mobileTerminalArtifactError(.fileNotFound, path: context.requestedPath) + } + } + private func mobileTerminalArtifactContext( params: [String: Any], requiresPath: Bool, includeScrollback: Bool = true, includeTerminalText: Bool = true - ) -> TerminalArtifactContextResolution { + ) async -> TerminalArtifactContextResolution { guard let workspaceID = v2RawString(params, "workspace_id")?.trimmingCharacters(in: .whitespacesAndNewlines), let surfaceID = v2RawString(params, "surface_id")?.trimmingCharacters(in: .whitespacesAndNewlines), !workspaceID.isEmpty, @@ -171,6 +201,13 @@ extension TerminalController { data: nil )) } + let scanAuthorizedPaths = requiresPath + ? await terminalArtifactAuthorizationStore.authorizedPaths( + workspaceID: workspaceID, + surfaceID: surfaceID + ) + : [] + let directoryAccessMode = mobileArtifactDirectoryAccessMode() return v2MainSync { () -> TerminalArtifactContextResolution in guard let resolved = mobileResolveWorkspaceAndSurface(params: params, requireTerminal: true), let resolvedSurfaceID = resolved.surfaceId, @@ -193,10 +230,14 @@ extension TerminalController { $0.currentOrMostRecentSessionRecord(surfaceID: resolvedSurfaceID.uuidString)?.sessionID } return .success(TerminalArtifactReadContext( + workspaceID: workspaceID, + surfaceID: surfaceID, terminalText: terminalText, workingDirectory: workingDirectory, requestedPath: v2RawString(params, "path"), - sessionID: sessionID + sessionID: sessionID, + scanAuthorizedPaths: scanAuthorizedPaths, + directoryAccessMode: directoryAccessMode )) } } @@ -278,35 +319,46 @@ private struct TerminalArtifactReadContext: Sendable { case forbidden } + let workspaceID: String + let surfaceID: String private let terminalText: String private let workingDirectory: String? let requestedPath: String? let sessionID: String? + private let scanAuthorizedPaths: Set + private let directoryAccessMode: ChatArtifactScope.DirectoryAccessMode init( + workspaceID: String, + surfaceID: String, terminalText: String, workingDirectory: String?, requestedPath: String?, - sessionID: String? + sessionID: String?, + scanAuthorizedPaths: Set, + directoryAccessMode: ChatArtifactScope.DirectoryAccessMode ) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID self.terminalText = terminalText self.workingDirectory = workingDirectory self.requestedPath = requestedPath self.sessionID = sessionID + self.scanAuthorizedPaths = scanAuthorizedPaths + self.directoryAccessMode = directoryAccessMode } - func scan() -> TerminalArtifactScanResponse { + func scan(includeDirectories: Bool) -> TerminalArtifactScanResponse { let reader = ArtifactByteReader() let scope = TerminalArtifactScope( terminalText: terminalText, workingDirectory: workingDirectory, - resolver: ChatArtifactScope.FoundationResolver() + resolver: ChatArtifactScope.FoundationResolver(), + directoryAccessMode: directoryAccessMode ) let artifacts = scope.artifactPaths(limit: 200).compactMap { path -> TerminalArtifactReference? in guard let stat = try? reader.stat(path: path) else { return nil } - // Terminal gallery v1 is file-only. Tap-to-path bypasses scan, so - // folder navigation and a smarter high-count strategy can be deferred. - guard !stat.isDirectory else { return nil } + guard includeDirectories || !stat.isDirectory else { return nil } return TerminalArtifactReference( path: path, kind: stat.kind, @@ -322,25 +374,64 @@ private struct TerminalArtifactReadContext: Sendable { _ operation: (ArtifactByteReader, String) throws -> T ) throws -> T { guard let requestedPath else { throw Error.forbidden } + let resolver = ChatArtifactScope.FoundationResolver() + let snapshotScope = ChatArtifactScope( + referencedPaths: scanAuthorizedPaths, + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) + if let canonicalPath = snapshotScope.canonicalFilePath(for: requestedPath) { + return try operation(ArtifactByteReader(), canonicalPath) + } let scope = TerminalArtifactScope( terminalText: terminalText, workingDirectory: workingDirectory, - resolver: ChatArtifactScope.FoundationResolver() + resolver: resolver, + directoryAccessMode: directoryAccessMode ) guard let canonicalPath = scope.canonicalPath(for: requestedPath) else { throw Error.forbidden } return try operation(ArtifactByteReader(), canonicalPath) } + + func authorizedDirectoryList( + _ operation: (ArtifactByteReader, String) throws -> T + ) throws -> T { + guard let requestedPath else { throw Error.forbidden } + let resolver = ChatArtifactScope.FoundationResolver() + let snapshotScope = ChatArtifactScope( + referencedPaths: scanAuthorizedPaths, + directoryAccessMode: directoryAccessMode, + resolver: resolver + ) + if let canonicalPath = snapshotScope.canonicalDirectoryListPath(for: requestedPath) { + return try operation(ArtifactByteReader(), canonicalPath) + } + let scope = TerminalArtifactScope( + terminalText: terminalText, + workingDirectory: workingDirectory, + resolver: resolver, + directoryAccessMode: directoryAccessMode + ) + guard let canonicalPath = scope.canonicalDirectoryListPath(for: requestedPath) else { + throw Error.forbidden + } + return try operation(ArtifactByteReader(), canonicalPath) + } } private struct TerminalArtifactWire { - static func payload(_ value: T) -> [String: Any]? { + static func result(_ value: T) -> TerminalController.V2CallResult { let coding = ChatWireCoding() guard let data = try? coding.encode(value), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil + return .err( + code: "internal_error", + message: "Failed to encode terminal artifact response", + data: nil + ) } - return object + return .ok(object) } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 4bb240b4e293..f98d098b82e2 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -16,6 +16,7 @@ import CmuxSwiftRenderUI import Carbon.HIToolbox import CMUXMobileCore import CMUXAgentLaunch +import CmuxAgentChat import Foundation import os import Bonsplit @@ -125,6 +126,7 @@ class TerminalController { @MainActor private(set) var authCoordinator: AuthCoordinator? @MainActor private(set) var browserSignInFlow: HostBrowserSignInFlow? @MainActor var agentChatTranscriptService: AgentChatTranscriptService? + nonisolated let terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore // Sendable value type; injected at construction so socket auth never reaches a global. nonisolated let passwordStore: SocketControlPasswordStore private nonisolated let socketPasswordFileWatcher: FileWatcher? @@ -351,6 +353,7 @@ class TerminalController { socketClientPreauthorizationLimiter: SocketClientPreauthorizationLimiter = .init( maximumConcurrentClaims: 32 ), + terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore = .init(), remoteProxyBroker: any RemoteProxyBrokering = RemoteProxyBroker( tunnelProvider: RemoteDaemonProxyTunnelProvider(strings: .appLocalized, ptyBridgeStrings: AppRemotePTYBridgeStrings()) ) @@ -362,6 +365,7 @@ class TerminalController { self.socketPasswordFileWatcher = socketPasswordFileWatcher self.socketClientCapabilityAuthority = Self.makeSocketClientCapabilityAuthority() self.socketClientPreauthorizationLimiter = socketClientPreauthorizationLimiter + self.terminalArtifactAuthorizationStore = terminalArtifactAuthorizationStore self.transport = transport self.remoteProxyBroker = remoteProxyBroker let serverEventTarget = ServerEventTarget() diff --git a/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift b/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift index 923be3524a83..697c25b2e4e5 100644 --- a/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift +++ b/cmuxTests/AgentChatSessionRegistryLifecycleTests.swift @@ -464,6 +464,39 @@ struct AgentChatSessionRegistryLifecycleTests { #expect(service.hasBoundedReadableTranscript(record)) } + @MainActor + @Test func restoreStyleInitializationRebuildsSurfaceLookup() throws { + let surfaceID = UUID().uuidString + let older = AgentChatSessionRecord( + sessionID: "older", + agentKind: .codex, + workspaceID: UUID().uuidString, + surfaceID: surfaceID, + workingDirectory: "/Users/example/project", + transcriptPath: "/tmp/older.jsonl", + state: .ended, + lastActivityAt: Date(timeIntervalSince1970: 100), + title: nil, + pid: nil + ) + let newer = AgentChatSessionRecord( + sessionID: "newer", + agentKind: .claude, + workspaceID: UUID().uuidString, + surfaceID: surfaceID, + workingDirectory: "/Users/example/project", + transcriptPath: "/tmp/newer.jsonl", + state: .ended, + lastActivityAt: Date(timeIntervalSince1970: 200), + title: nil, + pid: nil + ) + let registry = AgentChatSessionRegistry(restoredRecords: [older, newer]) + + let resolved = try #require(registry.currentOrMostRecentSession(surfaceID: surfaceID)) + #expect(resolved.sessionID == newer.sessionID) + } + private func temporaryHomeDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("cmux-agent-chat-\(UUID().uuidString)", isDirectory: true) diff --git a/docs/configuration.md b/docs/configuration.md index 9b48e10dbf10..921256b446a4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,6 +2,23 @@ Global app preferences live in `~/.config/cmux/cmux.json`. +## `mobile.artifactFolderAccess` + +Controls which files and folders cmux on iOS may browse after a chat references a directory or a directory path appears in a terminal. + +```json +{ + "mobile": { + "artifactFolderAccess": "subtree" + } +} +``` + +- `subtree` (default): authorize the referenced directory and its full subtree. +- `oneLevel`: preserve the previous rule, authorizing only immediate children and listing only the referenced directory itself. + +Authorization compares canonical paths after resolving symlinks. A symlink inside an authorized folder cannot grant access to a target outside that folder. + ## `paneBorderColor` and `activePaneBorderColor` Customize split-workspace pane boundaries controlled by cmux. diff --git a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved index 505700b66295..0e840e0469d4 100644 --- a/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ios/cmux.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "originHash" : "d9134cf84862c709f8ed0ba2bf6030fe66ea77a67fec9d41b789ed6c8eaf10a0", "pins" : [ + { + "identity" : "highlightr", + "kind" : "remoteSourceControl", + "location" : "https://github.com/raspu/Highlightr.git", + "state" : { + "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version" : "2.3.0" + } + }, { "identity" : "iroh-ffi", "kind" : "remoteSourceControl", diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 608b1b11f50d..4af65b265f6c 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4489,6 +4489,23 @@ } } }, + "mobile.settings.betaFeatures": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Beta Features" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベータ機能" + } + } + } + }, "mobile.settings.deleteAccount": { "extractionState": "manual", "localizations": { @@ -5305,6 +5322,40 @@ } } }, + "mobile.settings.showMissingFiles": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show missing files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "見つからないファイルを表示" + } + } + } + }, + "mobile.settings.terminalFilesChip": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal Files Chip" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルのファイルチップ" + } + } + } + }, "mobile.settings.wrapTitles": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxPackage/Package.resolved b/ios/cmuxPackage/Package.resolved index 5d937dbb14ce..ae0baae33106 100644 --- a/ios/cmuxPackage/Package.resolved +++ b/ios/cmuxPackage/Package.resolved @@ -1,42 +1,51 @@ { - "originHash" : "dc6d4db6aa973b9856528dbbf9756a616960bb3ed0daaa10e59f371203bb3a53", - "pins" : [ + "originHash": "dc6d4db6aa973b9856528dbbf9756a616960bb3ed0daaa10e59f371203bb3a53", + "pins": [ { - "identity" : "iroh-ffi", - "kind" : "remoteSourceControl", - "location" : "https://github.com/manaflow-ai/iroh-ffi.git", - "state" : { - "revision" : "31ec35c931028937db1910b6d623a3e078eb91da", - "version" : "1.0.2-cmux.2" + "identity": "highlightr", + "kind": "remoteSourceControl", + "location": "https://github.com/raspu/Highlightr.git", + "state": { + "revision": "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef", + "version": "2.3.0" } }, { - "identity" : "sentry-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/getsentry/sentry-cocoa.git", - "state" : { - "revision" : "53eb9bd5da18e208cfd80e86863d3f4c7ba21b1d", - "version" : "9.21.0" + "identity": "iroh-ffi", + "kind": "remoteSourceControl", + "location": "https://github.com/manaflow-ai/iroh-ffi.git", + "state": { + "revision": "31ec35c931028937db1910b6d623a3e078eb91da", + "version": "1.0.2-cmux.2" } }, { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", - "version" : "1.7.1" + "identity": "sentry-cocoa", + "kind": "remoteSourceControl", + "location": "https://github.com/getsentry/sentry-cocoa.git", + "state": { + "revision": "53eb9bd5da18e208cfd80e86863d3f4c7ba21b1d", + "version": "9.21.0" } }, { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" + "identity": "swift-asn1", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-asn1.git", + "state": { + "revision": "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version": "1.7.1" + } + }, + { + "identity": "swift-crypto", + "kind": "remoteSourceControl", + "location": "https://github.com/apple/swift-crypto.git", + "state": { + "revision": "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version": "3.15.1" } } ], - "version" : 3 + "version": 3 } diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index be5c799258a8..5e10419f37af 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1449,6 +1449,20 @@ } } }, + "mobile": { + "title": "mobile", + "description": "Mac-side settings for cmux on iOS.", + "type": "object", + "additionalProperties": false, + "properties": { + "artifactFolderAccess": { + "type": "string", + "enum": ["subtree", "oneLevel"], + "default": "subtree", + "description": "Controls whether a referenced or terminal-visible directory authorizes its full canonical subtree or only immediate children." + } + } + }, "markdown": { "title": "markdown", "description": "Built-in markdown viewer settings.",