From 2eb77d0be977c0be91d0ee3221062d7d117e1460 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 10 Sep 2026 07:36:53 -0400 Subject: [PATCH] slab: an Aesthetic Code rock is a code you can scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prompt rock for an Aesthetic Code session now draws the address of the piece that session is writing, instead of its tumbling sigil. The terminal was the wrong home for that QR: seventeen rows of half-blocks is the finest raster Terminal.app can make — it has no sixel, no inline images, no Kitty protocol — and it ate a third of the window. The menu bar has real pixels and no rows to spend. `PromptScanCode` renders with CoreImage at EC level M, a whole number of pixels per module with interpolation off, and a four-module quiet zone baked into the bitmap rather than left to whatever composites it. It refuses rather than guesses: an empty URL, a payload past capacity, or a module that would land under three device pixels all return nil, and nil means the ordinary stone. The status channel moved AROUND the code — the colour is a square offset behind the white card — because a QR lit by the scene's sun, tumbled, or tinted by status is a QR that does not scan. Nothing touches the modules. Playback returns early on these surfaces; only the pet name still blinks on a poke. The payload gains an `https://` prefix it does not carry in the marker: a phone camera reads a schemeless payload as text rather than an offer to open something. Verified by decoding the screen rather than trusting the drawing — `zbarimg` over a full 2816x1762 `screencapture` reads `https://aesthetic.computer/prompt~channel%20...~!autorun` back off the rock. Fallback exercised all four ways: with the URL, without it, on a `claude` rock that has one, and back again. Claude-Session: https://claude.ai/code/session_01AMHaRzWgfXei4MDV79zePz --- .../Sources/SlabMenubar/ClaudeSession.swift | 8 ++ .../Sources/SlabMenubar/Ledger.swift | 10 +- .../Sources/SlabMenubar/PromptScanCode.swift | 94 +++++++++++++++ .../SlabMenubar/PromptSigilOverlay.swift | 113 +++++++++++++++++- 4 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 slab/menubar-swift/Sources/SlabMenubar/PromptScanCode.swift 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) -- 2.51.2