Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43f167f
iOS: view artifacts referenced in agent sessions
azooz2003-bit Jul 8, 2026
8da4b52
iOS terminal view: tap-a-path + Files button to view on-screen artifacts
azooz2003-bit Jul 9, 2026
e8a3c58
iOS terminal gallery: Files-app list + icon grid popover for the rend…
azooz2003-bit Jul 9, 2026
a7b747e
iOS terminal: fix soft-wrapped path taps + terminal-scoped viewer errors
azooz2003-bit Jul 9, 2026
8e2d14e
iOS terminal: floating "N files" gallery chip as the primary signifier
azooz2003-bit Jul 9, 2026
1652931
iOS terminal: stitch soft-wrapped paths cut right after a trailing char
azooz2003-bit Jul 10, 2026
612132c
iOS terminal gallery: UI refinement pass (fit thumbnails, sheet chrom…
azooz2003-bit Jul 10, 2026
c089f67
iOS terminal gallery: sharpen for iOS 26 (Liquid Glass, no glass-on-g…
azooz2003-bit Jul 10, 2026
07201ea
iOS terminal gallery: session-wide scope with sections, stable paging…
Jul 13, 2026
b53d201
iOS terminal gallery: grid missing badge overlays the tile; Session f…
Jul 13, 2026
d2e8c07
Merge remote-tracking branch 'origin/main' into feat-ios-artifact-view
Jul 13, 2026
976512f
iOS terminal gallery: metadata hugs the file title
Jul 13, 2026
6f70169
Artifact discovery: fix gaps found by a 300-transcript real-data audit
Jul 13, 2026
776d64a
Codex artifact discovery: parse modern rollout patch_apply_end events
Jul 13, 2026
718f3ea
Session artifact discovery: full parity with the In-view detector
Jul 13, 2026
9f1d541
Fix CI: accessory enum stability + drop stray iOS workspace lockfile
Jul 14, 2026
f401ebf
iOS terminal chip counts the whole session
Jul 14, 2026
50d0063
Fix chip-count liveness, pay file-length budget debt, run CmuxAgentCh…
Jul 14, 2026
e47b9e7
Guard terminal artifact denial logs for release builds
Jul 14, 2026
1068594
Merge remote-tracking branch 'origin/main' into feat-ios-artifact-view
Jul 14, 2026
bffef35
Review fixes: restore ./file detection, product-terms error copy
Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1096,6 +1096,7 @@ jobs:
CmuxRemoteDaemon
CmuxRemoteWorkspace
CmuxRemoteSession
CmuxAgentChat
CmuxAuthRuntime
CmuxCommandPalette
CmuxControlSocket
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ tmp-*/

# Local dogfood scratch (screenshots, recordings) — never commit
artifacts/
!Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/
!Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Artifacts/*.swift
!Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/
!Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Artifacts/*.swift

# tmux verbose debug logs (tmux -v) that land in the cwd
tmux-*.log
1 change: 1 addition & 0 deletions Packages/Shared/CmuxAgentChat/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let package = Package(
.testTarget(
name: "CmuxAgentChatTests",
dependencies: ["CmuxAgentChat"],
resources: [.process("Resources")],
swiftSettings: [.swiftLanguageMode(.v6)]
),
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import Foundation
import ImageIO
import UniformTypeIdentifiers

/// Reads already-authorized artifact bytes and metadata from the local filesystem.
///
/// Authorization is intentionally outside this type. Callers must scope-check the
/// requested path before invoking these methods.
public struct ArtifactByteReader: Sendable {
/// Filesystem/decoder failures surfaced by artifact RPC handlers.
public enum Error: Swift.Error, Sendable {
/// The scoped path no longer exists or cannot be statted.
case fileNotFound
/// The operation does not apply to this media type.
case unsupportedMedia
}

/// Creates a byte reader.
public init() {}

/// Reads metadata for an already-authorized path.
public func stat(path: String) throws -> ChatArtifactStat {
let attributes = try attributes(path: path)
let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0
let modifiedAt = attributes[.modificationDate] as? Date ?? Date(timeIntervalSince1970: 0)
let kind = kind(path: path, isDirectory: isDirectory)
return ChatArtifactStat(
exists: true,
isDirectory: isDirectory,
size: size,
modifiedAt: modifiedAt,
kind: kind,
mimeType: mimeType(path: path, isDirectory: isDirectory)
)
}

/// Reads one clamped byte chunk for an already-authorized file path.
public func fetch(path: String, offset: Int64, length: Int) throws -> ChatArtifactChunk {
let stat = try stat(path: path)
guard !stat.isDirectory else { throw Error.unsupportedMedia }
guard let handle = FileHandle(forReadingAtPath: path) else {
throw Error.fileNotFound
}
defer { try? handle.close() }
let totalSize = stat.size
let clampedOffset = min(max(offset, 0), totalSize)
try handle.seek(toOffset: UInt64(clampedOffset))
let data = try handle.read(upToCount: max(0, length)) ?? Data()
let endOffset = clampedOffset + Int64(data.count)
return ChatArtifactChunk(
data: data,
offset: clampedOffset,
totalSize: totalSize,
eof: endOffset >= totalSize
)
}

/// Generates a JPEG thumbnail for an already-authorized image path.
public func thumbnail(path: String, maxDimension: Int) throws -> ChatArtifactThumbnail {
guard kind(path: path, isDirectory: false) == .image else {
throw Error.unsupportedMedia
}
let url = URL(fileURLWithPath: path)
guard FileManager.default.fileExists(atPath: path) else {
throw Error.fileNotFound
}
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
throw Error.unsupportedMedia
}
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimension,
kCGImageSourceCreateThumbnailWithTransform: true,
]
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary),
let destinationData = CFDataCreateMutable(nil, 0),
let destination = CGImageDestinationCreateWithData(
destinationData,
UTType.jpeg.identifier as CFString,
1,
nil
) else {
throw Error.unsupportedMedia
}
CGImageDestinationAddImage(destination, image, [
kCGImageDestinationLossyCompressionQuality: 0.82,
] as CFDictionary)
guard CGImageDestinationFinalize(destination) else {
throw Error.unsupportedMedia
}
return ChatArtifactThumbnail(
data: destinationData as Data,
pixelWidth: image.width,
pixelHeight: image.height
)
}

/// Lists up to 500 immediate children for an already-authorized directory.
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 values = try entry.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey])
let isDirectory = values.isDirectory ?? false
return ChatArtifactDirectoryEntry(
name: entry.lastPathComponent,
isDirectory: isDirectory,
size: Int64(values.fileSize ?? 0),
kind: kind(path: entry.path, isDirectory: isDirectory)
)
}
return ChatArtifactDirectoryListing(entries: listed)
}

/// Infers preview category from a path extension and directory flag.
public func kind(path: String, isDirectory: Bool) -> ChatArtifactKind {
if isDirectory { return .directory }
guard let type = UTType(filenameExtension: URL(fileURLWithPath: path).pathExtension) else {
return .binary
}
if type.conforms(to: .image) { return .image }
if type.conforms(to: .text) || type.conforms(to: .sourceCode) || type.conforms(to: .json) {
return .text
}
return .binary
}

private func attributes(path: String) throws -> [FileAttributeKey: Any] {
do {
return try FileManager.default.attributesOfItem(atPath: path)
} catch {
throw Error.fileNotFound
}
}

private func mimeType(path: String, isDirectory: Bool) -> String? {
guard !isDirectory,
let type = UTType(filenameExtension: URL(fileURLWithPath: path).pathExtension) else {
return nil
}
return type.preferredMIMEType
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation

/// One raw-data chunk returned by the Mac artifact fetch RPC.
public struct ChatArtifactChunk: Sendable, Equatable, Codable {
/// Raw bytes decoded from the RPC's base64 payload.
public let data: Data
/// Byte offset where this chunk begins.
public let offset: Int64
/// Total file size in bytes.
public let totalSize: Int64
/// Whether this chunk reaches the end of the file.
public let eof: Bool

/// Creates a fetch chunk.
///
/// - Parameters:
/// - data: Raw bytes in this chunk.
/// - offset: Byte offset where the chunk begins.
/// - totalSize: Total file size in bytes.
/// - eof: Whether this chunk reaches the end of the file.
public init(data: Data, offset: Int64, totalSize: Int64, eof: Bool) {
self.data = data
self.offset = offset
self.totalSize = totalSize
self.eof = eof
}

private enum CodingKeys: String, CodingKey {
case data = "data_b64"
case offset
case totalSize = "total_size"
case eof
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/// One immediate child entry in a Mac-hosted artifact directory.
public struct ChatArtifactDirectoryEntry: Sendable, Equatable, Codable, Identifiable {
/// Entry display name relative to the listed directory.
public let name: String
/// Whether this entry is a directory.
public let isDirectory: Bool
/// Raw byte size for files, or the filesystem-reported size for directories.
public let size: Int64
/// The entry preview category.
public let kind: ChatArtifactKind

/// Stable identity for SwiftUI lists.
public var id: String { name }

/// Creates a directory entry.
///
/// - Parameters:
/// - name: Entry display name relative to the listed directory.
/// - isDirectory: Whether this entry is a directory.
/// - size: Raw byte size or filesystem-reported directory size.
/// - kind: Preview category.
public init(name: String, isDirectory: Bool, size: Int64, kind: ChatArtifactKind) {
self.name = name
self.isDirectory = isDirectory
self.size = size
self.kind = kind
}

private enum CodingKeys: String, CodingKey {
case name
case isDirectory = "is_directory"
case size
case kind
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/// A capped listing of one Mac-hosted artifact directory.
public struct ChatArtifactDirectoryListing: Sendable, Equatable, Codable {
/// Directory entries sorted by name.
public let entries: [ChatArtifactDirectoryEntry]

/// Creates a directory listing.
///
/// - Parameter entries: Directory entries sorted by name.
public init(entries: [ChatArtifactDirectoryEntry]) {
self.entries = entries
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/// Typed artifact failures surfaced by chat event sources.
public enum ChatArtifactError: Error, Sendable, Equatable {
/// The connected Mac or fixture source does not support artifact RPCs.
case unsupported
/// The request parameters were malformed.
case invalidParams
/// The chat session no longer exists on the Mac.
case sessionNotFound
/// The requested path was not referenced by the session.
case forbidden
/// The path was in scope but no longer exists on the Mac.
case fileNotFound
/// The path is not an image or cannot be decoded as one.
case unsupportedMedia
/// The Mac-side artifact service is not wired.
case unavailable
/// The Mac could not be reached or did not answer.
case macUnreachable
/// The file exceeds the inline preview size limit.
case tooLarge(limitBytes: Int64)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// Stable transcript sort key carried between gallery pages.
public struct ChatArtifactGalleryCursor: Sendable, Equatable, Codable {
/// Snapshot generation that emitted the cursor.
public let generation: String
/// Last-reference sequence of the page's final row.
public let seq: Int
/// Path tie-breaker of the page's final row.
public let path: String

/// Creates a stable cursor.
public init(generation: String, seq: Int, path: String) {
self.generation = generation
self.seq = seq
self.path = path
}

/// Encodes this cursor as an opaque RPC token.
public func token() throws -> String {
try JSONEncoder().encode(self).base64EncodedString()
}

/// Decodes an opaque RPC token.
public init?(token: String) {
guard let data = Data(base64Encoded: token),
let decoded = try? JSONDecoder().decode(Self.self, from: data) else {
return nil
}
self = decoded
}
}
Loading
Loading