Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
132 commits
Select commit Hold shift + click to select a range
875eb1f
Remove the iOS agent GUI (mobile chat) pipeline
azooz2003-bit Jul 8, 2026
b064aef
Regenerate SwiftPM lockfiles after chat package removal
azooz2003-bit Jul 8, 2026
3998f8c
Exempt downstream lockfiles from path-only dependency deltas in resol…
azooz2003-bit Jul 8, 2026
b79553d
Add CmuxAgentReplica: replicated-state core for the agent GUI rebuild
azooz2003-bit Jul 9, 2026
e2c0e55
Add CmuxAgentTruthKit: Mac-side detection reducer and transcript deco…
azooz2003-bit Jul 9, 2026
f868fd4
Add agent-transcript-harvest: shape-only corpus inventory with decode…
azooz2003-bit Jul 9, 2026
e95eef2
Decoder v2: close observed corpus gaps in Claude/Codex transcript dec…
azooz2003-bit Jul 9, 2026
02e045d
Merge origin/main into feat-scrap-agent-gui
azooz2003-bit Jul 9, 2026
8242597
Add EntryPayload: typed wire-ready content model for transcript entries
azooz2003-bit Jul 9, 2026
7dd58e2
Add CmuxAgentWire: gui.v1 wire contract with byte-pinned goldens
azooz2003-bit Jul 9, 2026
229c926
Add Mac gui.v1 read path: AgentGUI service, adapters, journal pipelines
azooz2003-bit Jul 10, 2026
f95dad4
Add Mac gui.v1 send path: ticket ledger, ask registry, injection
azooz2003-bit Jul 10, 2026
caf9a63
Add CmuxAgentSync engine and iOS transport glue for gui.v1
azooz2003-bit Jul 10, 2026
0a81855
Add CmuxAgentGUIUI: flipped transcript list, physics, replay demo
azooz2003-bit Jul 10, 2026
629f80c
Transcript demo: launch gate, upright cells, encoder-generated fixture
azooz2003-bit Jul 10, 2026
bf39158
Bump CMUXMobileRootView budget for the transcript demo gate
azooz2003-bit Jul 10, 2026
145888c
Transcript UI interaction burn-down: viewport keyboard, honest pill, …
azooz2003-bit Jul 11, 2026
383ec35
Merge remote-tracking branch 'origin/feat-scrap-agent-gui' into feat-…
azooz2003-bit Jul 11, 2026
b126580
iOS GUI: derive terminal theme and compact turns
azooz2003-bit Jul 11, 2026
03c73ff
Merge remote-tracking branch 'origin/main' into feat-ios-gtheme-compa…
azooz2003-bit Jul 11, 2026
55d3f62
iOS GUI: integrate live themed conversation surface
azooz2003-bit Jul 11, 2026
b45b596
Remove stale mobile chat dispatch arm
azooz2003-bit Jul 11, 2026
6d3047c
Merge remote-tracking branch 'origin/main' into feat-ios-gtheme-compa…
azooz2003-bit Jul 11, 2026
818c326
iOS GUI: own the themed transcript canvas
azooz2003-bit Jul 11, 2026
7374957
Split AgentSync files to satisfy Swift budgets
azooz2003-bit Jul 11, 2026
07d6c2a
Merge remote-tracking branch 'origin/main' into feat-ios-gtheme-compa…
azooz2003-bit Jul 11, 2026
354b028
Add regression coverage for Agent GUI state resets
azooz2003-bit Jul 11, 2026
33aa242
Fix Agent GUI state reset projection
azooz2003-bit Jul 11, 2026
ba63c55
Fix Agent GUI app-host build diagnostics
azooz2003-bit Jul 11, 2026
fcbea96
Add failing Agent GUI reliability regressions
azooz2003-bit Jul 11, 2026
08037eb
Fix Agent GUI interaction and reliability gaps
azooz2003-bit Jul 12, 2026
c45e30f
Merge remote-tracking branch 'origin/main' into feat-ios-gtheme-compa…
azooz2003-bit Jul 12, 2026
3a05659
Fix turn-segment identity, expansion state reuse, and bare-agent dete…
azooz2003-bit Jul 12, 2026
e37e378
Add transcript density toggle: comfortable default, compact register
Jul 13, 2026
5ae6a8d
Fix composer glass rematerialization and density anchor spacing term
Jul 13, 2026
ac22bb6
Fix live-session GUI defects: empty state, sparse layout, tail settle…
Jul 14, 2026
c589568
Merge origin/main: tagged-instance pairing isolation (#7864)
Jul 15, 2026
a195fb7
Fix Agent GUI livelock on session-dense Macs: incremental truth, lazy…
Jul 15, 2026
43ef5e0
Wire AgentGUIIncrementalState and observation tests into the project
Jul 15, 2026
b32ff75
Merge origin/main: iOS reconnect and build isolation fix (#8299)
Jul 17, 2026
f404c4b
Repair merge fallout: restored capture list, live theme threading
Jul 20, 2026
792c978
Dogfood round 1: layout order coverage, activity sheet, mask fix, sel…
Jul 20, 2026
f4f4a33
Native scroll-edge effects on an unflipped bottom-origin transcript
Jul 21, 2026
77b6e33
Merge origin/main into feat-scrap-agent-gui
Jul 21, 2026
baa652b
Add failing Agent GUI conversation invariants
Jul 21, 2026
7424b55
Rebuild Agent GUI around bounded native paging
Jul 21, 2026
8e6bec9
Merge origin/main into feat-scrap-agent-gui
Jul 22, 2026
516b4e2
Fix actor initializer for Xcode 26.5
Jul 22, 2026
3e5f3c0
Update terminal ownership tests for folder taps
Jul 22, 2026
34adbf7
Match terminal coordinator argument order
Jul 22, 2026
879e175
Add failing dark transcript appearance test
Jul 22, 2026
538df37
Match transcript appearance to terminal contrast
Jul 22, 2026
2792c82
Add failing inline transcript image tests
Jul 22, 2026
f3f97d0
Preview transcript images inline
Jul 22, 2026
bbe1ec5
Add failing Agent GUI artifact context test
Jul 22, 2026
cbc0d39
Model observed session transcript evidence
Jul 22, 2026
095df5b
Authorize Agent GUI transcript artifacts
Jul 22, 2026
7a08e13
Add failing Agent GUI artifact viewer route test
Jul 22, 2026
af7664d
Keep live artifact loader in image viewers
Jul 22, 2026
0615e7d
Add failing Agent GUI sparse activity detail test
azooz2003-bit Jul 24, 2026
3c76019
Fail open sparse Agent GUI activity details
azooz2003-bit Jul 24, 2026
7313ce8
Add failing Agent Session asset scheme test
azooz2003-bit Jul 24, 2026
cb5ef96
Serve Agent Session assets through bundled scheme
azooz2003-bit Jul 24, 2026
e261bed
Report Agent Session surface health
azooz2003-bit Jul 24, 2026
85b0824
Add Agent Session submit control command
azooz2003-bit Jul 25, 2026
61354c2
Keep Agent Session socket submits responsive
azooz2003-bit Jul 25, 2026
27d7529
Attach Agent Session socket input while idle
azooz2003-bit Jul 25, 2026
4d680b0
Queue Agent Session bridge events until ready
azooz2003-bit Jul 25, 2026
981d281
Hydrate Agent Session active provider state
azooz2003-bit Jul 25, 2026
3cf230b
Bundle Agent Session bridge hydration
azooz2003-bit Jul 25, 2026
48d1b46
Harden Agent transcript detail sheets
azooz2003-bit Jul 25, 2026
825ac45
Add artifact image dimensions to chat metadata
azooz2003-bit Jul 25, 2026
b7b6c31
Prefer loopback simulator attach tickets
azooz2003-bit Jul 25, 2026
daeb82a
Make loaded Agent transcript tail jumps local
azooz2003-bit Jul 25, 2026
d0a5d4d
Add failing detached tail scroll test
azooz2003-bit Jul 25, 2026
0b3d09c
Settle detached transcript tail jumps after layout
azooz2003-bit Jul 25, 2026
fdc945b
Keep Agent transcript jump pill above composer
azooz2003-bit Jul 25, 2026
ac4cb06
Read Agent pill inset inside transcript subtree
azooz2003-bit Jul 25, 2026
12692c3
Confirm Agent tail scroll before hiding pill
azooz2003-bit Jul 25, 2026
3334667
Require visible tail before following transcript
azooz2003-bit Jul 25, 2026
5b24b80
Derive image metadata for Agent transcripts
azooz2003-bit Jul 25, 2026
af31fee
Improve Agent GUI previews and pending journals
azooz2003-bit Jul 25, 2026
e8746a7
Enrich image attachment metadata in Agent GUI journal
azooz2003-bit Jul 25, 2026
495aba5
Add transcript row growth regression coverage
azooz2003-bit Jul 25, 2026
955daff
Invalidate visible transcript row heights on reconfigure
azooz2003-bit Jul 25, 2026
1d2181d
Polish Agent GUI fallback transcript rows
azooz2003-bit Jul 25, 2026
b794fa4
Render transcript image previews inline
azooz2003-bit Jul 25, 2026
4274a01
Harden Agent GUI transcript selections
azooz2003-bit Jul 25, 2026
fb2ef66
Preserve attachment preview metadata aliases
azooz2003-bit Jul 25, 2026
67d5cd0
Advance semantic transcript jumps across pages
azooz2003-bit Jul 25, 2026
ed2948f
Merge origin/main into feat-scrap-agent-gui
azooz2003-bit Jul 25, 2026
19d54cc
Repair Agent GUI Xcode project wiring
azooz2003-bit Jul 25, 2026
a6fbf06
Fix Agent GUI mobile merge wiring
azooz2003-bit Jul 25, 2026
09658d7
Fix Agent GUI mobile transcript stability
azooz2003-bit Jul 25, 2026
71c3777
Retry transient Agent GUI image thumbnails
azooz2003-bit Jul 25, 2026
c85703b
Strengthen Agent GUI tail scroll settling
azooz2003-bit Jul 25, 2026
4a5038a
Infer markdown image preview dimensions
azooz2003-bit Jul 25, 2026
f074575
Render tool image previews inline in Agent GUI
azooz2003-bit Jul 25, 2026
c0e9d0f
Hide nonblocking Agent GUI history errors
azooz2003-bit Jul 25, 2026
f664c41
Preserve agent image preview metadata
azooz2003-bit Jul 25, 2026
b10985b
Keep streaming replies contiguous
azooz2003-bit Jul 25, 2026
38ee479
Reset hosted transcript row state by identity
azooz2003-bit Jul 25, 2026
3ae4216
Add markdown image ratio metadata regression tests
azooz2003-bit Jul 25, 2026
199ef31
Preserve markdown image ratio metadata
azooz2003-bit Jul 25, 2026
3e87d98
Add scroll-to-top tail-settle regression test
azooz2003-bit Jul 25, 2026
385d5ea
Cancel tail settle before transcript head jumps
azooz2003-bit Jul 25, 2026
70f3540
Preserve tool preview geometry metadata
azooz2003-bit Jul 25, 2026
d77e542
Enrich tool preview attachments before publish
azooz2003-bit Jul 25, 2026
978c960
Harden agent GUI preview metadata bridge
azooz2003-bit Jul 25, 2026
ceb28ca
Harden activity timeline row identity
azooz2003-bit Jul 25, 2026
b1955ea
Fix transcript prose row self sizing
azooz2003-bit Jul 25, 2026
5e41e5e
Refresh Swift file length budget
azooz2003-bit Jul 28, 2026
8e6f345
Refresh iOS package resolution
azooz2003-bit Jul 28, 2026
0a54abe
Merge remote-tracking branch 'origin/main' into feat-scrap-agent-gui
azooz2003-bit Jul 28, 2026
ee5d45b
Refresh iOS workspace package resolution
azooz2003-bit Jul 28, 2026
7551932
Refresh Swift file length budget after main merge
azooz2003-bit Jul 28, 2026
559304f
Add deterministic Agent GUI demo automation
azooz2003-bit Jul 28, 2026
9b3c6b2
Delay Agent GUI demo automation for capture
azooz2003-bit Jul 28, 2026
ead4eba
Restore agent chat transcript service wiring
azooz2003-bit Jul 28, 2026
71983d5
Merge remote-tracking branch 'origin/main' into feat-scrap-agent-gui
azooz2003-bit Jul 28, 2026
41c8f14
Refresh Swift file length budget after main merge
azooz2003-bit Jul 28, 2026
929dcea
Default iOS agent transcripts to compact
azooz2003-bit Jul 28, 2026
77405cb
Fix iOS transcript XL composer clipping
azooz2003-bit Jul 28, 2026
575c5e2
Wrap iOS transcript demo controls at XL sizes
azooz2003-bit Jul 28, 2026
ace72f8
Merge remote-tracking branch 'origin/main' into feat-scrap-agent-gui
azooz2003-bit Jul 31, 2026
42cb999
Refresh iOS package resolution after main merge
azooz2003-bit Jul 31, 2026
60f4b17
Restore displaySettings visibility for Surfaces extension after main …
azooz2003-bit Jul 31, 2026
32244ff
Refresh CmuxMobileShellUI package resolution
azooz2003-bit Jul 31, 2026
45042b2
Keep sentry-cocoa at main's 9.23.0 pin
azooz2003-bit Jul 31, 2026
07a566a
Annotate sanctioned mobile lint exceptions
azooz2003-bit Jul 31, 2026
59fa797
Annotate sanctioned namespace lint exceptions
azooz2003-bit Jul 31, 2026
fea8a66
Merge remote-tracking branch 'origin/main' into feat-scrap-agent-gui
azooz2003-bit Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
418 changes: 418 additions & 0 deletions .github/swift-file-length-budget.tsv

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CLI/CMUXCLI+CommandSuggestions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ extension CMUXCLI {
"__sidebar_footer_icon_balance",
"__tmux-compat",
"agent-hibernation",
"agent-session",
"ai-accounts",
"auth",
"bind-key",
Expand Down
105 changes: 105 additions & 0 deletions CLI/cmux.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4976,6 +4976,15 @@ struct CMUXCLI {
let payload = try client.sendV2(method: "surface.create", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2CreationSummary(payload, idFormat: idFormat, kinds: ["surface", "pane", "dock_surface", "dock_pane", "workspace"]))

case "agent-session":
try runAgentSessionCommand(
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowId
)

case "surface":
try runSurfaceCommand(
commandArgs: commandArgs,
Expand Down Expand Up @@ -6986,6 +6995,80 @@ struct CMUXCLI {
}
}

private func runAgentSessionCommand(
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
guard let subcommand = commandArgs.first?.lowercased() else {
throw CLIError(message: "agent-session requires a subcommand. Try: cmux agent-session submit --surface surface:1 \"hello\"")
}
switch subcommand {
case "submit":
let args = Array(commandArgs.dropFirst())
let (workspaceOpt, rem0) = parseOption(args, name: "--workspace")
let (surfaceOpt, rem1) = parseOption(rem0, name: "--surface")
let (windowOpt, rem2) = parseOption(rem1, name: "--window")
let (providerOpt, rem3) = parseOption(rem2, name: "--provider")
let (providerIDOpt, rem4) = parseOption(rem3, name: "--provider-id")
let (permissionOpt, rem5) = parseOption(rem4, name: "--permission-mode")
let (permissionCamelOpt, rem6) = parseOption(rem5, name: "--permissionMode")
let (textOpt, rem7) = parseOption(rem6, name: "--text")
let textArgs: [String]
if let separatorIndex = rem7.firstIndex(of: "--") {
textArgs = Array(rem7[rem7.index(after: separatorIndex)...])
} else {
if let unknown = rem7.first(where: { $0.hasPrefix("--") }) {
throw CLIError(message: "agent-session submit: unknown flag '\(unknown)'")
}
textArgs = rem7
}
let text = (textOpt ?? textArgs.joined(separator: " "))
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw CLIError(message: "agent-session submit requires text")
}

var params: [String: Any] = ["text": text]
let windowRaw = windowOpt ?? windowOverride
let winId = try normalizeWindowHandle(windowRaw, client: client)
if let winId { params["window_id"] = winId }

let workspaceArg = workspaceOpt ?? (windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId)
if let wsId { params["workspace_id"] = wsId }

let sfId = try normalizeSurfaceHandle(surfaceOpt, client: client, workspaceHandle: wsId, windowHandle: winId)
if let sfId { params["surface_id"] = sfId }

if let provider = providerOpt ?? providerIDOpt {
params["provider_id"] = provider
}
if let permissionMode = permissionOpt ?? permissionCamelOpt {
params["permission_mode"] = permissionMode
}

let payload = try client.sendV2(
method: "agent_session.submit",
params: params,
responseTimeout: 125
)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else {
let provider = payload["provider_id"] as? String ?? "agent"
let started = (payload["started_provider"] as? Bool) == true
? " started_provider=true"
: ""
print("\(v2OKSummary(payload, idFormat: idFormat, kinds: ["surface", "workspace"])) provider=\(provider)\(started)")
}
default:
throw CLIError(message: "Unsupported agent-session subcommand: \(subcommand)")
}
}

private func runSurfaceResumeCommand(
commandArgs: [String],
client: SocketClient,
Expand Down Expand Up @@ -16712,6 +16795,27 @@ struct CMUXCLI {
cmux new-surface --type agent-session --provider claude --renderer solid --focus true
cmux new-surface --type browser --placement dock --url https://example.com
"""
case "agent-session":
return """
Usage: cmux agent-session submit [flags] [--] <text>

Submit text to an Agent Session surface. Starts the selected provider if it is not running.

Flags:
--workspace <id|ref|index> Workspace context (default: $CMUX_WORKSPACE_ID)
--surface <id|ref|index> Agent Session surface to target
--window <id|ref|index> Window context for workspace/surface refs and indexes
--provider <codex|claude|opencode>
Provider to start when no session is running
--permission-mode <default|auto-review|full-access|custom>
Permission mode for the submitted turn
--text <text> Text to submit instead of positional text
--json Structured JSON output

Example:
cmux agent-session submit --surface surface:2 --provider codex "Say hello"
cmux agent-session submit --workspace workspace:1 -- --text that begins with dashes
"""
case "close-surface":
return """
Usage: cmux close-surface [flags]
Expand Down Expand Up @@ -36118,6 +36222,7 @@ export default CMUXSessionRestore;
focus-pane --pane <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>]
new-pane [--type <terminal|browser|simulator>] [--direction <left|right|up|down>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--url <url>] \(String(localized: "cli.browser.profile.option", defaultValue: "[--profile <name|uuid>]")) [--focus <true|false>]
new-surface [--type <terminal|browser|simulator|agent-session>] [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--url <url>] [--provider <codex|claude|opencode>] [--renderer <react|solid>] [--focus <true|false>]
agent-session submit [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--provider <codex|claude|opencode>] [--permission-mode <default|auto-review|full-access|custom>] [--text <text>|<text>]
close-surface [--surface <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>]
move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>]
split-off --surface <id|ref|index> <left|right|up|down> [--workspace <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Foundation
///
/// Device identifiers outside the UUID grammar are opaque protocol values and
/// are returned byte-for-byte, including their original case and whitespace.
// lint:allow free-function - public cross-package canonicalization API, keep source compatibility.
public func cmxCanonicalDeviceID(_ deviceID: String) -> String {
guard let uuid = UUID(uuidString: deviceID) else { return deviceID }
return uuid.uuidString.lowercased()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ public final class DiagnosticLog: Sendable {
commandContinuation: AsyncStream<DrainCommand>.Continuation
) {
let (events, eventContinuation) = Self.makeEventSegment(capacity: capacity)
// lint:allow lock - same sanctioned synchronous DiagnosticLog state lock as the property above.
self.state = OSAllocatedUnfairLock(initialState: State(
capacity: capacity,
commandContinuation: commandContinuation,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/// Capability advertised by a Mac that supports browser-pane streaming.
// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout.
public struct MobileBrowserStreamCapability: Sendable {
private init() {}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// Validates that a terminal replay was captured after the host applied the
/// viewport accepted for the requesting client.
// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout.
public enum MobileTerminalReplayViewportFence {
/// Legacy replay requests do not carry a viewport and remain accepted.
/// Viewport-aware requests are accepted only when both cell dimensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Foundation
/// scroll further back at the cost of a proportionally larger one-time
/// download at connect (~250 bytes per row); the visible screen never waits
/// on it.
// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout.
public enum MobileTerminalScrollbackPreference {
/// UserDefaults key, written by Settings and read at hydration time.
public static let defaultsKey = "cmux.mobile.terminalScrollbackRows"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public struct MobileWorkspaceDescriptionProjection: Equatable, Sendable {
}

/// Shared bounds for workspace metadata that travels between Mac and iOS.
// lint:allow namespace-type - existing static helper surface, preserving call sites during UI closeout.
public enum MobileWorkspaceMetadataLimits {
/// Keep durable workspace descriptions well below the 8 MiB mobile frame cap.
public static let customDescriptionMaxUTF8Bytes = 4096
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,115 @@ public struct ArtifactByteReader: Sendable {
isDirectory: isDirectory,
isRegularFile: fileType == .typeRegular
)
let imageDimensions = imageDimensions(path: path, kind: kind)
return ChatArtifactStat(
exists: true,
isDirectory: isDirectory,
size: size,
modifiedAt: modifiedAt,
kind: kind,
mimeType: mimeType(path: path, isDirectory: isDirectory)
mimeType: mimeType(path: path, isDirectory: isDirectory),
pixelWidth: imageDimensions?.width,
pixelHeight: imageDimensions?.height
)
}

private func imageDimensions(path: String, kind: ChatArtifactKind) -> (width: Int, height: Int)? {
guard kind == .image else { return nil }
let url = URL(fileURLWithPath: path)
if let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = Self.intProperty(properties[kCGImagePropertyPixelWidth]),
let height = Self.intProperty(properties[kCGImagePropertyPixelHeight]),
width > 0,
height > 0 {
return (width, height)
}
return svgDimensions(path: path)
}

private static func intProperty(_ value: Any?) -> Int? {
switch value {
case let number as NSNumber:
return number.intValue
case let int as Int:
return int
default:
return nil
}
}

private func svgDimensions(path: String) -> (width: Int, height: Int)? {
guard URL(fileURLWithPath: path).pathExtension.lowercased() == "svg",
let handle = try? FileHandle(forReadingFrom: URL(fileURLWithPath: path))
else { return nil }
defer { try? handle.close() }
let data = (try? handle.read(upToCount: 128 * 1_024)) ?? Data()
guard let text = String(data: data, encoding: .utf8),
text.range(of: "<svg", options: [.caseInsensitive]) != nil else {
return nil
}
if let width = svgLength(named: "width", in: text),
let height = svgLength(named: "height", in: text),
width > 0,
height > 0 {
return (width, height)
}
guard let viewBox = svgAttribute(named: "viewBox", in: text) else {
return nil
}
let numbers = viewBox
.split { $0 == "," || $0 == " " || $0 == "\n" || $0 == "\t" }
.compactMap { Double($0) }
guard numbers.count >= 4,
numbers[2] > 0,
numbers[3] > 0 else {
return nil
}
return (roundedPixelDimension(numbers[2]), roundedPixelDimension(numbers[3]))
}

private func svgLength(named name: String, in text: String) -> Int? {
guard let rawValue = svgAttribute(named: name, in: text),
let number = leadingNumber(rawValue),
number > 0 else {
return nil
}
return roundedPixelDimension(number)
}

private func svgAttribute(named name: String, in text: String) -> String? {
let escapedName = NSRegularExpression.escapedPattern(for: name)
let pattern = #"\b\#(escapedName)\s*=\s*["']([^"']+)["']"#
guard let expression = try? NSRegularExpression(
pattern: pattern,
options: [.caseInsensitive]
) else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
guard let match = expression.firstMatch(in: text, range: range),
let matchRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[matchRange])
}

private func leadingNumber(_ value: String) -> Double? {
let pattern = #"^[ \t\r\n]*([+-]?(?:\d+(?:\.\d+)?|\.\d+))"#
guard let expression = try? NSRegularExpression(pattern: pattern),
let match = expression.firstMatch(
in: value,
range: NSRange(value.startIndex..<value.endIndex, in: value)
),
let matchRange = Range(match.range(at: 1), in: value) else {
return nil
}
return Double(value[matchRange])
}

private func roundedPixelDimension(_ value: Double) -> Int {
max(1, Int(value.rounded()))
}

/// Generates a JPEG thumbnail for an already-authorized image path.
public func thumbnail(path: String, maxDimension: Int) throws -> ChatArtifactThumbnail {
let opened = try openVerifiedRegularFile(path: path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl
public let childCount: Int?
/// Whether ``childCount`` is the listing cap rather than the exact count.
public let childCountIsCapped: Bool
/// Intrinsic image width in pixels, when available.
public let pixelWidth: Int?
/// Intrinsic image height in pixels, when available.
public let pixelHeight: Int?
/// Transcript provenance after precedence-based de-duplication.
public let provenance: ChatArtifactProvenance

Expand All @@ -34,6 +38,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl
exists: Bool = true,
childCount: Int? = nil,
childCountIsCapped: Bool = false,
pixelWidth: Int? = nil,
pixelHeight: Int? = nil,
provenance: ChatArtifactProvenance = .referenced
) {
self.path = path
Expand All @@ -44,6 +50,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl
self.exists = exists
self.childCount = childCount
self.childCountIsCapped = childCountIsCapped
self.pixelWidth = Self.validPixelDimension(pixelWidth)
self.pixelHeight = Self.validPixelDimension(pixelHeight)
self.provenance = provenance
}

Expand All @@ -56,6 +64,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl
case exists
case childCount = "child_count"
case childCountIsCapped = "child_count_is_capped"
case pixelWidth = "pixel_width"
case pixelHeight = "pixel_height"
case provenance
}

Expand All @@ -74,6 +84,8 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl
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
pixelWidth = Self.validPixelDimension(try? container.decode(Int.self, forKey: .pixelWidth))
pixelHeight = Self.validPixelDimension(try? container.decode(Int.self, forKey: .pixelHeight))
provenance = (try? container.decode(ChatArtifactProvenance.self, forKey: .provenance)) ?? .referenced
}

Expand All @@ -89,6 +101,13 @@ public struct ChatArtifactGalleryItem: Sendable, Equatable, Codable, Identifiabl
try container.encode(exists, forKey: .exists)
try container.encodeIfPresent(childCount, forKey: .childCount)
try container.encode(childCountIsCapped, forKey: .childCountIsCapped)
try container.encodeIfPresent(pixelWidth, forKey: .pixelWidth)
try container.encodeIfPresent(pixelHeight, forKey: .pixelHeight)
try container.encode(provenance, forKey: .provenance)
}

private static func validPixelDimension(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return value
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ public struct ChatArtifactGalleryRowEligibility: Sendable {
exists: stat.exists,
childCount: children?.count,
childCountIsCapped: children?.isCapped ?? false,
pixelWidth: stat.pixelWidth,
pixelHeight: stat.pixelHeight,
provenance: reference.provenance
)
} catch {
Expand Down
Loading