diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift index 1fda0d752e..669933661a 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -98,6 +98,13 @@ struct ClaudeSession { /// prompt changes, titles, and terminal retiling without name-based rules. var platformTarget: String = "" + /// Where a phone can reach the piece this session is holding, as a bare + /// host+path (`aesthetic.computer/prompt~…`) with no scheme. Only + /// Aesthetic Code mints one, and only once its piece exists, so an empty + /// string is the ordinary case and means "no scannable destination yet". + /// The prompt rock turns this into a QR surface; everything else ignores it. + var scanURL: String = "" + /// Native provider thread id. Claude uses `sessionId`; Codex's tracked /// wrapper has its own rock id, so the watcher records the rollout id here. var providerSessionId: String = "" @@ -363,6 +370,7 @@ enum ClaudeSessionReader { session.loopboyState = (obj["loopboy_state"] as? String) ?? "" session.loopboyResponse = (obj["loopboy_response"] as? String) ?? "" session.nudgeScreen = (obj["nudge_screen"] as? String) ?? "" + session.scanURL = (obj["scan_url"] as? String) ?? "" return session } diff --git a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift index 2ae4e48e8b..f1049af999 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/Ledger.swift @@ -48,6 +48,10 @@ struct LedgerEntry: Codable, Equatable { /// Guarded launch identity. Optional keeps older fleet ledgers readable; /// unlike the route registry, this originates in the live marker. var loopboyContact: String? + /// Bare host+path a phone can scan to reach the piece this prompt is + /// holding. Only Aesthetic Code mints one. Optional both because older + /// fleet ledgers predate it and because most prompts never have one. + var scanURL: String? } struct Ledger: Codable { @@ -401,7 +405,8 @@ final class LedgerStore { memoir: ProxMemoirs.shared.text(for: s.sessionId), agentType: s.agentType, platformTarget: s.platformTarget.isEmpty ? nil : s.platformTarget, - loopboyContact: s.loopboyContact.isEmpty ? nil : s.loopboyContact) + loopboyContact: s.loopboyContact.isEmpty ? nil : s.loopboyContact, + scanURL: s.scanURL.isEmpty ? nil : s.scanURL) } entries.append(contentsOf: advertisedAgents()) @@ -448,7 +453,8 @@ final class LedgerStore { memoir: (obj["memoir"] as? String), agentType: (obj["agent_type"] as? String), platformTarget: (obj["platform_target"] as? String), - loopboyContact: (obj["loopboy_contact"] as? String))) + loopboyContact: (obj["loopboy_contact"] as? String), + scanURL: (obj["scan_url"] as? String))) } return out } diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift new file mode 100644 index 0000000000..65a503a6c5 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift @@ -0,0 +1,94 @@ +// PROMPT SCAN CODES. +// +// An Aesthetic Code session is holding a live piece, and the way you reach that +// piece from a phone is a QR. Printed into the terminal it costs seventeen rows +// of half-blocks and shoves the actual conversation off the top of the pane; +// carried by the session's prompt rock it costs nothing but the stone's own +// square, and it sits where the eye already looks for that session. +// +// Nothing here is stylised, and that is the point. A QR stops being a QR the +// moment it is lit, tinted, softened, or tumbled: it is solid black on solid +// white, it keeps a real quiet zone, and every module is a whole number of +// device pixels so no edge is ever resampled into grey. The rock's lighting +// model, its translucency and its rotation are all deliberately withheld from +// this surface — the status channel moves around the code, never through it. + +import AppKit +import CoreImage + +enum PromptScanCode { + /// Modules of white kept around the code. Four is the spec's minimum for a + /// reliable read. CIQRCodeGenerator already draws a one-module border of + /// its own, which lands *inside* this margin rather than counting as it. + private static let quietModules = 4 + + /// The smallest module we are willing to draw, in device pixels. Below + /// this the code is a texture rather than something a phone camera can + /// resolve across a desk, and the caller is better off with the stone. + private static let minimumModulePixels = 3 + + /// Points-to-pixels for the rendered bitmap. Fixed at 2 rather than read + /// from the current screen: a Retina rock gets exact 1:1 pixels, and a + /// 1x display halves an even module size cleanly instead of landing the + /// modules on fractional pixel boundaries when a window changes screens. + static let renderScale: CGFloat = 2 + + private static let ciContext = CIContext(options: [.useSoftwareRenderer: true]) + + /// The marker carries a bare host+path — `aesthetic.computer/prompt~…` — + /// because that is what reads well in a terminal. A phone camera wants a + /// URL it can open, and a schemeless payload is only text to it, so the + /// scheme is added here rather than asked of the session that minted it. + static func scannablePayload(_ url: String) -> String { + let trimmed = url.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "" } + return trimmed.contains("://") ? trimmed : "https://" + trimmed + } + + /// Render `url` as a black-on-white code sized to sit inside a + /// `surfacePoints` square. Returns nil when there is nothing to encode, + /// when the payload is too long for a QR at all, or when the code would + /// only fit at a module size no camera could resolve — in every one of + /// those cases the caller falls back to the ordinary sigil rock. + static func render(url: String, surfacePoints: CGFloat) -> CGImage? { + let payload = scannablePayload(url) + guard !payload.isEmpty, surfacePoints > 0, + let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + filter.setValue(Data(payload.utf8), forKey: "inputMessage") + // "M" recovers ~15% of the symbol. On a screen there is no print + // damage to recover from, so anything higher only buys modules — and + // more modules at a fixed surface means smaller ones, which is the + // opposite of what makes this scan. + filter.setValue("M", forKey: "inputCorrectionLevel") + guard let output = filter.outputImage else { return nil } + + let modules = Int(output.extent.width.rounded()) + guard modules > 0, + let code = ciContext.createCGImage(output, from: output.extent) + else { return nil } + + // One module is an exact integer square of device pixels, so the + // nearest-neighbour blit below can only ever produce hard edges. + let total = modules + quietModules * 2 + let modulePixels = Int(surfacePoints * renderScale) / total + guard modulePixels >= minimumModulePixels else { return nil } + + let side = total * modulePixels + guard let ctx = CGContext( + data: nil, width: side, height: side, bitsPerComponent: 8, + bytesPerRow: side * 4, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) + else { return nil } + // The quiet zone is part of the bitmap, not a background colour the + // compositor is trusted to paint: whatever the rock is sitting on + // top of, the code arrives with its own white margin already drawn. + ctx.setFillColor(CGColor(gray: 1, alpha: 1)) + ctx.fill(CGRect(x: 0, y: 0, width: side, height: side)) + ctx.interpolationQuality = .none + ctx.setShouldAntialias(false) + let inset = CGFloat(quietModules * modulePixels) + let span = CGFloat(modules * modulePixels) + ctx.draw(code, in: CGRect(x: inset, y: inset, width: span, height: span)) + return ctx.makeImage() + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index 209629e0e6..93aad10290 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -431,7 +431,18 @@ final class PromptSigilOverlay { private var targetOrigin: NSPoint? private var currentOrigin: NSPoint? - let size: CGFloat = 56 + /// How wide a scan surface is. A stone is 56 points; a code has to stay + /// legible to a phone camera held at desk distance, and shrunk to stone + /// size its modules stop resolving. Even at this size it eats far less of + /// the pane than the seventeen rows of half-blocks it replaces. + static let scanSurfaceSize: CGFloat = 140 + + /// True when this rock shows a scannable code instead of the tumbling + /// sigil. Fixed at construction, because it decides the surface's + /// geometry — the controller builds a fresh overlay when a session gains + /// or loses its scan URL. + let isScanSurface: Bool + let size: CGFloat /// Padding around the rock inside the window so the offset drop shadow has /// room and isn't clipped by the window edge. private let pad: CGFloat = 9 @@ -453,6 +464,7 @@ final class PromptSigilOverlay { private let shadowLayer = CALayer() // solid status colour, masked to the rock silhouette private let shadowMask = CALayer() // plays the same frames → the shadow's tumbling shape private let nameLayer = CALayer() // the rock's pet name, under the rock (pixel-text bitmap) + private let scanLayer = CALayer() // flat white card carrying the QR, on scan surfaces only private let heartbeatTrackLayer = CALayer() private let heartbeatFillLayer = CALayer() private let heartbeatFuseEmitter = CAEmitterLayer() @@ -490,10 +502,14 @@ final class PromptSigilOverlay { /// silhouette the shadow plays. private var rockFrames: [CGImage] = [] private var shadowFrames: [CGImage] = [] + /// The flat code this surface is showing, when it is a scan surface. + private(set) var scanImage: CGImage? - init(sessionId: String, tty: String) { + init(sessionId: String, tty: String, scanSurface: Bool = false) { self.sessionId = sessionId self.tty = tty + self.isScanSurface = scanSurface + self.size = scanSurface ? Self.scanSurfaceSize : 56 let box = size + 2 * pad let initial = NSRect(x: -2000, y: -2000, width: box, height: box + labelH) @@ -574,6 +590,27 @@ final class PromptSigilOverlay { rockLayer.contentsScale = 1 container.layer?.addSublayer(rockLayer) + // A scan surface keeps the rock's place in the world and gives up its + // material. The tumbling frames, the silhouette mask and the sun's + // shading are all withheld, because every one of them would cost the + // code a read: what remains of the stone is its status colour, now a + // plain offset square peeking out from behind the card, and its pet + // name underneath. `contentsGravity = .center` with a matching + // `contentsScale` lands one bitmap pixel on one device pixel, so the + // modules are never resampled; the white ground is a backstop for the + // few points of surface the code's own quiet zone doesn't reach. + if scanSurface { + rockLayer.isHidden = true + shadowLayer.mask = nil + scanLayer.frame = CGRect(x: pad, y: pad + labelH, width: size, height: size) + scanLayer.backgroundColor = NSColor.white.cgColor + scanLayer.contentsGravity = .center + scanLayer.contentsScale = PromptScanCode.renderScale + scanLayer.magnificationFilter = .nearest + scanLayer.minificationFilter = .nearest + container.layer?.addSublayer(scanLayer) + } + // Transparent space above/below lets smoke rise and ash fall without // the heartbeat window clipping either stream. heartbeatTrackLayer.frame = CGRect(x: 0, y: 24, width: heartbeatInitial.width, height: 3) @@ -1050,6 +1087,12 @@ final class PromptSigilOverlay { func setFrames(rock: [CGImage], shadow: [CGImage]) { rockFrames = rock shadowFrames = shadow + // A scan surface still grows its stone, because the collectible card + // it opens on click is a portrait of the session, not a second copy of + // the code. It just never installs those frames: the sigil would be + // playing behind an opaque card, and letting `positionLabels` measure + // its silhouette would drag the pet name up over the code. + guard !isScanSurface else { return } rockLayer.contents = rock.first shadowMask.contents = shadow.first positionLabels(for: rock) @@ -1089,6 +1132,17 @@ final class PromptSigilOverlay { stateLayer.frame.origin.y = max(1, nameY - 18) } + /// Install the rendered code. Flat, static, and swapped without an + /// implicit CALayer crossfade — a half-faded QR is an unreadable one. + func setScanCode(_ image: CGImage?) { + guard isScanSurface, scanImage !== image else { return } + scanImage = image + CATransaction.begin() + CATransaction.setDisableActions(true) + scanLayer.contents = image + CATransaction.commit() + } + /// Tint the flat drop-shadow disc with the session's status colour. private var shadowColor: NSColor? private var promptColor: NSColor? @@ -1226,6 +1280,9 @@ final class PromptSigilOverlay { } private func applyPlayback() { + // A code does not tumble. Nothing about a scan surface is animated, + // so it never joins the compositor's playback at all. + guard !isScanSurface else { return } guard !ecoPaused, rockFrames.count > 1, let (period, cw) = motion else { rockLayer.removeAnimation(forKey: "tumble") shadowMask.removeAnimation(forKey: "tumble") @@ -1863,6 +1920,12 @@ final class PromptSigilOverlayController { /// Serial queue for the offscreen 3D frame renders — one rock at a time so /// a per-minute sun change never spins up 7 Metal renderers at once. private let renderQueue = DispatchQueue(label: "computer.slab.sigil-frames", qos: .userInitiated) + /// One rendered code per scan URL. `sync` runs several times a second and + /// the generator is not free, so each destination is drawn once and + /// dropped as soon as no live prompt points there. A URL that cannot be + /// encoded at all is remembered too, so it isn't retried every tick. + private var scanCodes: [String: CGImage] = [:] + private var unscannableURLs: Set = [] /// Hover/click plumbing for the rocks. The badge windows stay /// mouse-transparent (clicks still reach the terminal beneath); GLOBAL @@ -2533,6 +2596,27 @@ final class PromptSigilOverlayController { /// Reconcile the badge set with the live sessions. Off when `enabled` is /// false. Only sessions with a real local tty get a badge. + /// The code this session's rock should be showing, or nil for the stone. + /// Every gate lives here: only Aesthetic Code mints scan URLs, the URL has + /// to exist, and it has to survive the generator — a payload too long for a + /// QR, or one that would only fit at a module size no camera can resolve, + /// comes back nil and the session keeps an ordinary rock. + private func scanCode(for s: ClaudeSession) -> CGImage? { + guard s.agentType == "aesthetic-code" else { return nil } + let url = s.scanURL + guard !url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !unscannableURLs.contains(url) else { return nil } + if let cached = scanCodes[url] { return cached } + guard let image = PromptScanCode.render( + url: url, surfacePoints: PromptSigilOverlay.scanSurfaceSize) + else { + unscannableURLs.insert(url) + return nil + } + scanCodes[url] = image + return image + } + func sync(sessions: [ClaudeSession], enabled: Bool) { guard enabled else { teardown(); return } installActivationObserverIfNeeded() @@ -2559,6 +2643,7 @@ final class PromptSigilOverlayController { } var membershipChanged = false + var liveScanURLs = Set() for (sid, ov) in overlays where !liveIds.contains(sid) { ov.close(); overlays.removeValue(forKey: sid); membershipChanged = true if bubbleFor == sid { @@ -2569,12 +2654,22 @@ final class PromptSigilOverlayController { for s in live { let bare = (s.tty as NSString).lastPathComponent liveParticleTtys.insert(bare) + // An Aesthetic Code session is holding a piece, and the client for + // that piece is a phone. When the marker says where to send it, the + // rock trades its stone for a code the camera can take. Nil — an + // older session, a piece that hasn't minted yet, another agent + // entirely, a URL too long to encode — leaves the stone standing. + let scanCode = scanCode(for: s) + let scanSurface = scanCode != nil + if scanSurface { liveScanURLs.insert(s.scanURL) } let ov: PromptSigilOverlay - if let existing = overlays[s.sessionId], existing.tty == bare { + if let existing = overlays[s.sessionId], existing.tty == bare, + existing.isScanSurface == scanSurface { ov = existing } else { overlays[s.sessionId]?.close() - ov = PromptSigilOverlay(sessionId: s.sessionId, tty: bare) + ov = PromptSigilOverlay(sessionId: s.sessionId, tty: bare, + scanSurface: scanSurface) ov.onHoverChange = { [weak self] rock, hovering in self?.handleDirectHover(rock, hovering: hovering) } @@ -2590,6 +2685,7 @@ final class PromptSigilOverlayController { // moves to a new prompt. let seed = SigilRenderer.seed(for: s.sessionId + "\u{1}" + s.subject) ov.soundSeed = seed + ov.setScanCode(scanCode) // Re-render the sprite sheet only when the rock or the sun moved. let loopboy = loopIds.contains(s.sessionId) let key = "\(seed):\(dark):\(sunMinute):\(loopboy)" @@ -2644,6 +2740,11 @@ final class PromptSigilOverlayController { ov.setName(SigilRenderer.name(for: s), dark: dark) let title = s.emoji.isEmpty ? ov.name : "\(s.emoji) \(ov.name)" ov.tooltipTitle = loopboy ? "↻ Loopboy · \(title)" : title + // The card says what kind of object this is; a scan rock is not + // offering a memory, it is offering a way in. + ov.tooltipEdition = scanSurface + ? "PROMPT ROCK • SCAN TO OPEN" + : "PROMPT ROCK • LIVING MEMORY" let story = (s.loopboyResponse.isEmpty ? nil : s.loopboyResponse) ?? ProxMemoirs.shared.text(for: s.sessionId) ?? Self.fallbackBody(summary: s.titleString, subject: s.shortSubject) @@ -2657,6 +2758,8 @@ final class PromptSigilOverlayController { } } particleColors = particleColors.filter { liveParticleTtys.contains($0.key) } + scanCodes = scanCodes.filter { liveScanURLs.contains($0.key) } + unscannableURLs = unscannableURLs.filter { liveScanURLs.contains($0) } if membershipChanged { needsRebind = true } startTimerIfNeeded() @@ -2718,6 +2821,8 @@ final class PromptSigilOverlayController { overlays.removeAll() binding.removeAll() particleColors.removeAll() + scanCodes.removeAll() + unscannableURLs.removeAll() removeMouseMonitors() for (_, obs) in axObservers { CFRunLoopRemoveSource(CFRunLoopGetMain(), AXObserverGetRunLoopSource(obs), .commonModes)