diff --git a/easel/bin/design-loop.mjs b/easel/bin/design-loop.mjs new file mode 100755 index 0000000000..d56298e276 --- /dev/null +++ b/easel/bin/design-loop.mjs @@ -0,0 +1,215 @@ +#!/usr/bin/env node +// design-loop — look at Easel's own furniture, change it, look again. +// +// Easel does not draw all of itself. The QR you scan, the little live card of +// the piece, the stone that carries the session's status: those are Slab +// menubar overlays parked on top of the terminal, and they are the first thing +// anyone sees. Adjusting them used to be guesswork, for two reasons that +// compounded: +// +// 1. `frame` deliberately filters Slab's own overlays out of every capture — +// the usual job is reading the machine's real content *beneath* them — so +// a screenshot taken to check the card's padding showed the terminal +// where the card is. Changing a number and looking at the result meant +// looking at the wrong picture. `--overlays` is the opt-out, and this +// tool always passes it. +// 2. The overlays are placed once, when a session's window appears. Editing +// the placement and reinstalling the menubar does not move the surfaces +// already on screen in any way you can trust, so the only honest check is +// a *fresh* session. That is the restart this tool performs. +// +// So: close the Easel session, open a new one, wait for its overlays to exist, +// point the camera at them. One command per iteration, and the picture that +// comes back is the thing being designed. +// +// node easel/bin/design-loop.mjs # restart Easel, then shoot +// node easel/bin/design-loop.mjs --shot # shoot what is already open +// node easel/bin/design-loop.mjs --out ~/x.jpg +// +// After an overlay edit the order is: install.sh in slab/menubar-swift, then +// this. Reinstalling without restarting the session measures the old layout. + +import { spawn } from "node:child_process"; +import { readFile, readdir, mkdir } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const FRAME = join(REPO, "slab", "bin", "frame.mjs"); +const MARKERS = join(homedir(), ".local", "share", "slab", "state", "active-prompts"); +const LEDGER = join(homedir(), ".config", "slab", "ledger", "local.json"); + +// The resident prox daemon, shared by every Claude session on this machine. +// Going through it rather than reimplementing close/launch means this tool +// inherits prox's own refusals — it will not close the session calling it, and +// it will not launch anything outside the allowlist. +const PROX = "http://127.0.0.1:7773"; + +const argv = process.argv.slice(2); +const flag = (name) => argv.includes(name); +const opt = (name) => { const i = argv.indexOf(name); return i === -1 ? null : argv[i + 1]; }; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function prox(name, args = {}) { + const response = await fetch(`${PROX}/`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: Date.now(), + method: "tools/call", params: { name, arguments: args }, + }), + signal: AbortSignal.timeout(30_000), + }).catch((e) => { + throw new Error(`prox daemon unreachable on ${PROX} — is the menubar's MCP up? (${e.message})`); + }); + const body = await response.json(); + const text = (body?.result?.content || []).map((c) => c.text || "").join("\n"); + if (body?.result?.isError) throw new Error(`${name}: ${text}`); + return text; +} + +/// Every live Easel session on this machine, newest marker first. Read from +/// the marker files rather than asked of prox: this runs between a close and a +/// launch, when the ledger cache is the one thing guaranteed to be stale. +async function easelSessions() { + let names = []; + try { names = await readdir(MARKERS); } catch { return []; } + const out = []; + for (const name of names) { + try { + const marker = JSON.parse(await readFile(join(MARKERS, name), "utf8")); + if (marker.agent_type !== "easel") continue; + out.push({ ...marker, id: marker.session_id || name }); + } catch { /* a marker mid-write is not an error, it is a retry */ } + } + return out.sort((a, b) => String(b.updated || "").localeCompare(String(a.updated || ""))); +} + +/// Wait for an Easel session that is not one we already knew about, and that +/// has got far enough to own a terminal and a piece. `scan_url` is the signal +/// that matters: it is set at the moment the session has an address to encode, +/// which is the moment the QR and the preview card come into existence. Waiting +/// on the marker's mere existence would photograph the overlays mid-placement. +async function waitForFreshSession(known, timeoutMs = 45_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + for (const session of await easelSessions()) { + if (known.has(session.id)) continue; + if (session.tty && session.scan_url) return session; + } + await sleep(500); + } + return null; +} + +/// Bring a session's Terminal window to the front by its tty, because the +/// window id is not in the marker and the tty is. A frame of an unfocused +/// window still captures pixels, but the overlays belong to whichever window +/// is on top — photographing a buried one photographs somebody else's rock. +async function focusTty(tty) { + const script = ` + tell application "Terminal" + activate + repeat with w in windows + repeat with t in tabs of w + if tty of t is "/dev/${tty}" then + set selected tab of w to t + set index of w to 1 + return "ok" + end if + end repeat + end repeat + return "missing" + end tell`; + const result = await run("osascript", ["-e", script]); + return result.stdout.trim() === "ok"; +} + +function run(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`${command} exited ${code}: ${stderr.trim().slice(0, 400)}`)); + }); + }); +} + +/// The capture itself. `--overlays` is the whole point; `--quiet-overlay` keeps +/// OCR from drawing its own boxes over the thing being judged, and `--no-ocr` +/// skips the text pass entirely, because this picture is read by an eye. +async function shoot(out) { + await mkdir(dirname(out), { recursive: true }).catch(() => {}); + const { stdout } = await run(process.execPath, [ + FRAME, "local", "--overlays", "--no-ocr", "--quiet-overlay", "--json", "--out", out, + ]); + let envelope = {}; + try { envelope = JSON.parse(stdout); } catch { /* the file is what matters */ } + return envelope; +} + +async function selfHost() { + try { return JSON.parse(await readFile(LEDGER, "utf8")).host; } catch { return null; } +} + +const out = opt("--out") || join(tmpdir(), `easel-design-${Date.now()}.jpg`); + +if (flag("--help") || flag("-h")) { + console.log([ + "design-loop — restart Easel and photograph its overlays", + "", + " node easel/bin/design-loop.mjs [--shot] [--out file.jpg] [--cwd dir]", + "", + " --shot: skip the restart and shoot the session already open", + " --out: where the JPEG lands (default: a temp file, path printed)", + " --cwd: working directory for the new session (default: this repo)", + " --settle: seconds to let the overlays land first (default 4)", + "", + " Edit an overlay, run slab/menubar-swift/install.sh, then run this.", + ].join("\n")); + process.exit(0); +} + +let session = (await easelSessions())[0] || null; + +if (!flag("--shot")) { + const host = await selfHost(); + if (!host) throw new Error(`no local ledger at ${LEDGER} — is the Slab menubar running?`); + const known = new Set((await easelSessions()).map((s) => s.id)); + + if (session) { + console.log(`⟲ closing ${host}:easel (${session.piece || session.id.slice(0, 8)})`); + await prox("prox_close", { handle: session.id }); + } else { + console.log("⟲ no Easel session open — launching a first one"); + } + + console.log("⟳ launching a fresh Easel"); + await prox("prox_launch", { host, agent: "easel", cwd: opt("--cwd") || REPO, by: "easel:design-loop" }); + + session = await waitForFreshSession(known); + if (!session) throw new Error("the new Easel never reported a scan URL — nothing to photograph."); + // The marker is written the moment the address exists; the overlays are + // placed on the menubar's next walk of the window list, which is a separate + // clock this tool cannot read. Measured at about three seconds on blueberry — + // a shot at 1.5s caught the terminal with no card and no QR on it, which is + // the same wrong picture the `--overlays` flag exists to prevent. Four is + // that with room. If the overlays still have not landed, `--shot` takes + // another look without paying for another session. + await sleep(Number(opt("--settle")) * 1000 || 4000); +} + +if (!session) throw new Error("no Easel session is open — drop --shot to launch one."); +if (!(await focusTty(session.tty))) { + console.log(`! could not focus /dev/${session.tty} — shooting whatever is frontmost`); +} + +const envelope = await shoot(out); +console.log(`📷 ${out} (${envelope.capture_scope || "?"})`); +console.log(` ${session.handle ? "@" + session.handle + "/" : ""}${session.piece || "?"} · ${session.scan_url || "no scan url"}`); diff --git a/easel/src/live.mjs b/easel/src/live.mjs index c0b0c84e68..772f4bd885 100644 --- a/easel/src/live.mjs +++ b/easel/src/live.mjs @@ -77,6 +77,9 @@ export class LivePiece extends EventEmitter { this.debounce = null; this.sending = false; this.pushes = 0; + // A save is on disk that the channel has not been told about yet. Set when + // the watcher arms, cleared when the push it armed lands. + this.ahead = false; } get file() { @@ -216,6 +219,7 @@ export class LivePiece extends EventEmitter { this.sending = false; } this.pushes += 1; + this.ahead = false; this.emit("push", this.pushes); return true; } @@ -236,6 +240,14 @@ export class LivePiece extends EventEmitter { this.watcher = watch(this.directory, (_event, name) => { if (name && name !== basename(this.file)) return; clearTimeout(this.debounce); + // The save has landed on disk and has not left for the channel yet. + // Anything watching the piece — the preview in the corner of the pane + // most of all — is showing the version before this one until the push + // goes, and would otherwise have no way to know it. + if (!this.ahead) { + this.ahead = true; + this.emit("dirty"); + } this.debounce = setTimeout(() => { this.push().catch(onError); }, 250); diff --git a/easel/src/slab-session.mjs b/easel/src/slab-session.mjs index 4a3558a39c..22443b9425 100644 --- a/easel/src/slab-session.mjs +++ b/easel/src/slab-session.mjs @@ -65,6 +65,11 @@ export class SlabSession { // up to, rather than seventeen rows of half-blocks inside the transcript. piece: "", scan_url: "", + // How the piece at that address stands against the file on disk. The + // preview parked opposite the rock renders the address; these say whether + // what it is showing is the current save, a save still on its way, or a + // push in flight. Without them a frozen frame and a live one look alike. + flow: "live", provider_agent_type: "codex", provider_session_id: "", updated: now(), @@ -105,6 +110,19 @@ export class SlabSession { }); } + // Where the file stands against what the address is serving: + // live — the channel has the current save + // ahead — saved, not pushed yet + // pushing — the push is in flight + // Deliberately one word rather than three booleans: the overlay draws one + // badge, and a state that can be both "ahead" and "pushing" at once is a + // question about which to draw that nobody has to answer if it cannot arise. + flow(state = "live") { + const clean = ["live", "ahead", "pushing"].includes(state) ? state : "live"; + if (this.record.flow === clean) return; + this.#update({ flow: clean }); + } + working(prompt = "") { this.#remove(this.awaiting); this.#touch(this.running); diff --git a/slab/bin/frame-mcp.mjs b/slab/bin/frame-mcp.mjs index f4ab248f4e..b512f8588c 100755 --- a/slab/bin/frame-mcp.mjs +++ b/slab/bin/frame-mcp.mjs @@ -162,7 +162,7 @@ const recentActionTrails = new Map(); const recentFrames = new Map(); const visionCache = new Map(); -async function captureFrame({ machine, ocr = true, fast = false, screen = false, cursor = true, cursorAt, targetAt, targetId, manualCheck, pressAt, pressCount = 1, pressTitle, actionOnly = false, clearTarget = false, clearOverlays = false, quietOverlay = false, crop, baseline = false, diff = false } = {}) { +async function captureFrame({ machine, ocr = true, fast = false, screen = false, cursor = true, cursorAt, targetAt, targetId, manualCheck, pressAt, pressCount = 1, pressTitle, actionOnly = false, clearTarget = false, clearOverlays = false, quietOverlay = false, overlays = false, crop, baseline = false, diff = false } = {}) { if (!machine) throw new Error("`machine` is required (see frame_list)"); // A unique path matters now that an action trail may capture while another // session asks for a normal frame of the same machine. @@ -182,6 +182,7 @@ async function captureFrame({ machine, ocr = true, fast = false, screen = false, if (clearTarget) args.push("--clear-target"); if (clearOverlays) args.push("--clear-overlays"); if (quietOverlay) args.push("--quiet-overlay"); + if (overlays) args.push("--overlays"); if (crop) args.push("--crop", crop.join(",")); if (baseline) args.push("--baseline"); if (diff) args.push("--diff"); @@ -1063,6 +1064,7 @@ const TOOLS = [ ocr: { type: "boolean", description: "Run OCR (default true). Set false for a faster pixels+AX-only frame." }, fast: { type: "boolean", description: "Use Vision .fast OCR — lower latency, less accurate on small text (default false)." }, cursor: { type: "boolean", description: "Draw a high-contrast virtual cursor at the current mouse position (default true)." }, + overlays: { type: "boolean", description: "Keep slab's own overlays — prompt rocks, the QR, the piece preview — in the shot instead of filtering them out, and widen the window crop to reach the menu bar. Use it when the overlays themselves are what you are designing (default false)." }, }, required: ["machine"], }, diff --git a/slab/bin/frame.mjs b/slab/bin/frame.mjs index 425fa5c4d0..e159cda84c 100755 --- a/slab/bin/frame.mjs +++ b/slab/bin/frame.mjs @@ -408,7 +408,7 @@ function directFrame(name, machines, mode, timeoutMs = 15000) { }); } -async function captureFrame(name, { noOCR = false, fast = false, screen = false, cursor = false, cursorAt, targetAt, targetId, manualCheck, pressAt, pressCount = 1, pressTitle, actionOnly = false, clearTarget = false, clearOverlays = false, quietOverlay = false, crop, baseline = false, diff = false, out, json = false, direct = false, preview = false } = {}) { +async function captureFrame(name, { noOCR = false, fast = false, screen = false, cursor = false, cursorAt, targetAt, targetId, manualCheck, pressAt, pressCount = 1, pressTitle, actionOnly = false, clearTarget = false, clearOverlays = false, quietOverlay = false, overlays = false, crop, baseline = false, diff = false, out, json = false, direct = false, preview = false } = {}) { if (name === XBOX_TARGET) { if (actionOnly || targetAt || targetId || manualCheck || pressAt || pressTitle || clearTarget || clearOverlays) { throw new Error("xbox is an observe-only Frame target; use the native gamepad/live-publish loop for control"); @@ -453,7 +453,7 @@ async function captureFrame(name, { noOCR = false, fast = false, screen = false, console.error(`unknown machine "${name}" — known: ${Object.keys(machines).join(", ") || "(none)"}`); process.exit(1); } - const mode = [screen ? "screen" : "window", noOCR ? "noocr" : "full", fast ? "fast" : "", cursorAt ? `cursor=${cursorAt[0]},${cursorAt[1]}` : cursor ? "cursor" : "", targetAt ? `target=${targetAt[0]},${targetAt[1]}` : "", targetId ? `target-id=${targetId}` : "", manualCheck ? `manual-check=${manualCheck}` : "", pressAt ? `press=${pressAt[0]},${pressAt[1]},${pressCount}` : "", pressTitle ? `press-title=${Buffer.from(pressTitle, "utf8").toString("base64")}` : "", actionOnly ? "action-only" : "", clearTarget ? "target-clear" : "", clearOverlays ? "overlay-clear" : "", quietOverlay ? "quiet-overlay" : "", crop ? `crop=${crop.join(",")}` : "", baseline ? "baseline" : "", diff ? "diff" : ""] + const mode = [screen ? "screen" : "window", noOCR ? "noocr" : "full", fast ? "fast" : "", cursorAt ? `cursor=${cursorAt[0]},${cursorAt[1]}` : cursor ? "cursor" : "", targetAt ? `target=${targetAt[0]},${targetAt[1]}` : "", targetId ? `target-id=${targetId}` : "", manualCheck ? `manual-check=${manualCheck}` : "", pressAt ? `press=${pressAt[0]},${pressAt[1]},${pressCount}` : "", pressTitle ? `press-title=${Buffer.from(pressTitle, "utf8").toString("base64")}` : "", actionOnly ? "action-only" : "", clearTarget ? "target-clear" : "", clearOverlays ? "overlay-clear" : "", quietOverlay ? "quiet-overlay" : "", overlays ? "overlays" : "", crop ? `crop=${crop.join(",")}` : "", baseline ? "baseline" : "", diff ? "diff" : ""] .filter(Boolean).join(" "); // Use the resident server only if already running (opt-in; see runServer); // otherwise a one-shot direct ssh. Both return an ACF1 {json, jpg} frame — @@ -606,7 +606,7 @@ const pointOpt = (f) => { if (!cmd || cmd === "-h" || cmd === "--help") { console.log( "frame — capture the focused window (pixels + OCR + AX + state) of a remote Mac\n\n" + - " frame [--screen] [--no-ocr] [--fast] [--cursor] [--target-at x,y] [--target-id id] [--manual-check id] [--press-at x,y] [--action-only] [--clear-target] [--clear-overlays] [--quiet-overlay] [--direct] [--preview] [--out file.jpg] [--json]\n" + + " frame [--screen] [--no-ocr] [--fast] [--cursor] [--target-at x,y] [--target-id id] [--manual-check id] [--press-at x,y] [--action-only] [--clear-target] [--clear-overlays] [--quiet-overlay] [--overlays] [--direct] [--preview] [--out file.jpg] [--json]\n" + " --screen: capture the complete display instead of the focused window\n" + " --fast: Vision .fast OCR (lower latency, less accurate on small text)\n" + " --cursor: draw a high-contrast virtual marker at the mouse position\n" + @@ -618,6 +618,7 @@ if (!cmd || cmd === "-h" || cmd === "--help") { " --clear-target: clear the persistent proposed-click marker\n" + " --clear-overlays: retire all Frame-owned transient overlay windows\n" + " --quiet-overlay: perform OCR without drawing its on-screen boxes\n" + + " --overlays: keep slab's own rocks and previews in the shot (for designing them)\n" + " --direct: bypass the resident server, do a one-shot ssh\n" + " --preview: pop a labeled preview window of the pulled frame on THIS Mac\n" + " frame view capture + open the badged preview window (= --preview)\n" + @@ -667,4 +668,4 @@ else if (cmd === "view") { label: opt("--label"), }); console.log(JSON.stringify(r, null, 2)); -} else await captureFrame(cmd, { noOCR: flag("--no-ocr"), fast: flag("--fast"), screen: flag("--screen"), cursor: flag("--cursor"), cursorAt: pointOpt("--cursor-at"), targetAt: pointOpt("--target-at"), targetId: opt("--target-id"), manualCheck: opt("--manual-check"), pressAt: pointOpt("--press-at"), pressCount: Number(opt("--press-count") || 1), pressTitle: opt("--press-title"), actionOnly: flag("--action-only"), clearTarget: flag("--clear-target"), clearOverlays: flag("--clear-overlays"), quietOverlay: flag("--quiet-overlay"), crop: opt("--crop")?.split(",").map(Number), baseline: flag("--baseline"), diff: flag("--diff"), direct: flag("--direct"), out: opt("--out"), json: flag("--json"), preview: flag("--preview") }); +} else await captureFrame(cmd, { noOCR: flag("--no-ocr"), fast: flag("--fast"), screen: flag("--screen"), cursor: flag("--cursor"), cursorAt: pointOpt("--cursor-at"), targetAt: pointOpt("--target-at"), targetId: opt("--target-id"), manualCheck: opt("--manual-check"), pressAt: pointOpt("--press-at"), pressCount: Number(opt("--press-count") || 1), pressTitle: opt("--press-title"), actionOnly: flag("--action-only"), clearTarget: flag("--clear-target"), clearOverlays: flag("--clear-overlays"), quietOverlay: flag("--quiet-overlay"), overlays: flag("--overlays"), crop: opt("--crop")?.split(",").map(Number), baseline: flag("--baseline"), diff: flag("--diff"), direct: flag("--direct"), out: opt("--out"), json: flag("--json"), preview: flag("--preview") }); diff --git a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift index 5786c99906..a9946fcab1 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/ClaudeSession.swift @@ -111,6 +111,12 @@ struct ClaudeSession { /// by a second unrelated word drawn from its session id. var piece: String = "" + /// How the file on disk stands against what `scanURL` is serving — + /// `live`, `ahead` (saved, not pushed yet) or `pushing`. The preview + /// parked opposite the rock renders that address, and this is the only + /// thing that can tell a current frame from one save behind. + var flow: String = "live" + /// 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 = "" @@ -378,6 +384,7 @@ enum ClaudeSessionReader { session.nudgeScreen = (obj["nudge_screen"] as? String) ?? "" session.scanURL = (obj["scan_url"] as? String) ?? "" session.piece = (obj["piece"] as? String) ?? "" + session.flow = (obj["flow"] as? String) ?? "live" return session } diff --git a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift index d6b7b9d32f..1bddffccea 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/FrameCapture.swift @@ -34,6 +34,11 @@ final class FrameCapture { // Vision's recognition threshold. The OCR scale uses the same factor so // box coords still map back to screen points. private let captureScale: Double = 1.0 + /// How far outside the window an overlay-inclusive frame reaches. The rock + /// sits above the title bar and the complaint about it is its distance from + /// the menu bar, so a shot that stops at the window edge cannot answer the + /// question being asked of it. + private static let overlayMargin: Double = 180 private let overlayLock = NSLock() private var overlayWindowIDs = Set() private var ocrOverlayWindow: NSWindow? @@ -129,7 +134,8 @@ final class FrameCapture { wholeScreen: mode.contains("screen"), virtualCursor: mode.contains("cursor"), cursorOverride: cursorOverride, crop: crop, saveBaseline: mode.contains("baseline"), includeDiff: mode.contains("diff"), - showOverlay: !mode.contains("quiet-overlay")) + showOverlay: !mode.contains("quiet-overlay"), + includeOverlays: mode.contains("overlays")) if let pendingClickTarget { showPendingClickTarget(at: pendingClickTarget, approvalId: pendingClickApprovalId) } @@ -717,7 +723,8 @@ final class FrameCapture { /// so their coordinates remain directly click-ready even for window-only /// images. private func captureDisplay(crop: CGRect? = nil, - focusedWindowID: CGWindowID? = nil) -> + focusedWindowID: CGWindowID? = nil, + includeOverlays: Bool = false) -> (image: CGImage?, region: CGRect, scope: String) { guard #available(macOS 14.0, *) else { return (nil, .zero, "screen") } let sem = DispatchSemaphore(value: 0) @@ -734,7 +741,18 @@ final class FrameCapture { NSLog("Frame capture: shareable content failed: %@", String(describing: error)) return } - if crop == nil, let focusedWindowID, + // Asking for the overlays and then capturing a desktop-independent + // window is a contradiction: that path renders the window alone, so + // whatever is parked on top of it is exactly what gets dropped. + // Widen instead — shoot the display, cropped to the window plus a + // margin, so the rock above the title bar and the menu bar it is + // said to be flush against both land in the same picture. + var cropRect = crop + if includeOverlays, crop == nil, let focusedWindowID, + let window = content.windows.first(where: { $0.windowID == focusedWindowID }) { + cropRect = window.frame.insetBy(dx: -Self.overlayMargin, dy: -Self.overlayMargin) + } + if cropRect == nil, let focusedWindowID, let window = content.windows.first(where: { $0.windowID == focusedWindowID }) { let region = window.frame let filter = SCContentFilter(desktopIndependentWindow: window) @@ -761,8 +779,13 @@ final class FrameCapture { // Suspenders: the explicitly-tracked overlay window ids, in case a // window's owning app is momentarily unresolved. A frame is always // the machine's real content beneath our overlays — never them. + // Hiding our own overlays is right for the usual job — reading the + // machine's real content — and exactly wrong when the overlays are + // the subject. Adjusting the rock's padding or the preview's shadow + // against a capture that filters both out is working blind, so the + // exclusion is a default rather than a rule. let myBundle = Bundle.main.bundleIdentifier - let exclude = content.windows.filter { w in + let exclude = includeOverlays ? [] : content.windows.filter { w in if w.owningApplication?.bundleIdentifier == myBundle { return true } self.overlayLock.lock(); defer { self.overlayLock.unlock() } return self.overlayWindowIDs.contains(Int(w.windowID)) @@ -770,10 +793,10 @@ final class FrameCapture { let filter = SCContentFilter(display: display, excludingWindows: exclude) let cfg = SCStreamConfiguration() var region = CGRect(x: 0, y: 0, width: display.width, height: display.height) - if let crop = crop { - region = crop.intersection(CGRect(x: 0, y: 0, width: display.width, height: display.height)) + if let wanted = cropRect { + region = wanted.intersection(CGRect(x: 0, y: 0, width: display.width, height: display.height)) cfg.sourceRect = region - capturedScope = "crop" + capturedScope = (includeOverlays && crop == nil) ? "window+overlays" : "crop" } capturedRegion = region cfg.width = Int(Double(region.width) * self.captureScale) @@ -1066,7 +1089,8 @@ final class FrameCapture { private func produce(noOCR: Bool, fast: Bool = false, wholeScreen: Bool = false, virtualCursor: Bool = false, cursorOverride: CGPoint? = nil, crop: CGRect? = nil, saveBaseline: Bool = false, - includeDiff: Bool = false, showOverlay: Bool = true) { + includeDiff: Bool = false, showOverlay: Bool = true, + includeOverlays: Bool = false) { func nowNs() -> UInt64 { DispatchTime.now().uptimeNanoseconds } func msSince(_ t: UInt64) -> Double { (Double(nowNs() - t) / 1e6 * 10).rounded() / 10 } var env: [String: Any] = [:] @@ -1102,7 +1126,8 @@ final class FrameCapture { env["meta"] = mt t = nowNs() let captured = captureDisplay(crop: boundedCrop, - focusedWindowID: wholeScreen ? nil : target) + focusedWindowID: wholeScreen ? nil : target, + includeOverlays: includeOverlays) let cg = captured.image let captureRegion = captured.region env["capture_scope"] = captured.scope diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift new file mode 100644 index 0000000000..6d39243f30 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift @@ -0,0 +1,428 @@ +// PROMPT PREVIEWS. +// +// The piece an Easel session is holding, running in the top-left of that +// session's own terminal pane — opposite the rock, which carries the QR to the +// same address. Two corners saying the same thing to two different readers: the +// stone is for a phone across the desk, this is for the eyes already on the +// pane. +// +// Before this, seeing your own piece meant `/open` and a browser window that +// immediately fell behind the terminal grid, or scanning your own rock with +// your own phone. Neither is a glance. A preview that is simply always up +// costs one decision fewer every time you want to know what you just made. +// +// What it shows is `scan_url` — the same address the rock encodes — so the two +// surfaces can never disagree about which piece this session is about. It runs +// capped (`maxfps`) because a wall of nine panes each animating at display rate +// is a warm laptop for no one's benefit; under the pointer the cap comes off +// and the window grows, which is the whole interaction. +// +// The chrome over it is not decoration. A web view that is covered, throttled +// or simply one save behind shows a frame that looks exactly like a live one, +// and that lie is worse than no preview at all — so the badge's only job is to +// say which of those you are looking at. + +import AppKit +import SwiftUI +import WebKit + +/// Where the file on disk stands against what the previewed address is serving. +/// Mirrors `flow` in easel's `slab-session.mjs`; unknown strings read as `live` +/// so an older session never renders as a warning. +enum PromptFlow: String { + case live + case ahead + case pushing + + init(_ raw: String) { self = PromptFlow(rawValue: raw) ?? .live } +} + +/// Everything the badge needs to say, resolved to one line by `PromptPreview` +/// so the view itself holds no policy. +struct PromptPreviewState: Equatable { + var flow: PromptFlow = .live + var working = false + /// The pane is covered, so WebKit has throttled the view and the pixels on + /// screen are however old the last paint was. Never inferred from a + /// timer — read from the same window-stack test that hides the rock. + var paused = false + var piece: String = "" + + /// Nothing to report: the card is showing the current piece, painting, and + /// the file agrees with it. The overwhelmingly common case, and the one the + /// chrome says NOTHING about — a badge reading "live" over every pane all + /// day is a label you stop seeing, which makes the four states that matter + /// harder to notice, not easier. Silence is the resting state. + var quiet: Bool { flow == .live && !working && !paused } + + /// Only spoken when `quiet` is false. + var label: String { + if paused { return "paused" } + if working { return "working" } + switch flow { + case .pushing: return "pushing" + case .ahead: return "ahead" + case .live: return "" + } + } + + var tint: Color { + if paused { return Color(white: 0.62) } + if working { return Color(red: 1.0, green: 0.78, blue: 0.22) } + switch flow { + case .pushing: return Color(red: 0.40, green: 0.72, blue: 1.0) + case .ahead: return Color(red: 1.0, green: 0.55, blue: 0.30) + case .live: return Color(white: 0.5) + } + } +} + +/// The badge, which is usually not there at all. It appears to say one of four +/// things — ahead, pushing, working, paused — and the piece's name, but only +/// while the card is grown, where a name is something you asked for rather than +/// something parked over the art. A piece is free to paint white, so the chip +/// carries its own dark ground rather than trusting contrast. +private struct PromptPreviewBadge: View { + let state: PromptPreviewState + let expanded: Bool + + /// The name earns its place only in the grown card; the state earns its + /// place only when there is one. Both absent is the ordinary case, and the + /// view draws nothing at all. + private var text: String { + let name = expanded ? state.piece : "" + if state.quiet { return name } + return name.isEmpty ? state.label : "\(name) · \(state.label)" + } + + var body: some View { + if text.isEmpty { + EmptyView() + } else { + HStack(spacing: 5) { + if !state.quiet { + Circle() + .fill(state.tint) + .frame(width: 6, height: 6) + } + Text(text) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.92)) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Capsule().fill(Color.black.opacity(0.62))) + .overlay( + Capsule().strokeBorder( + state.quiet ? Color.white.opacity(0.18) : state.tint.opacity(0.45), + lineWidth: 0.5) + ) + } + } +} + +final class PromptPreview { + /// The resting card. Deliberately bigger than the rock: a stone reads as a + /// status light at 56 points, but a piece has to be recognisable as itself, + /// and below about this it is a texture. + static let restSize = CGSize(width: 128, height: 96) + /// The most a pointed-at card will grow to. It is a ceiling, not a size — + /// `hoverSize(in:)` fits the card to the pane it belongs to, because a + /// tiled grid's panes are all different and a card that grew past its own + /// pane would spill onto the neighbour, fail the ownership test that keeps + /// companion surfaces honest, and blink out at the exact moment you asked + /// to see it. + private static let hoverCap = CGSize(width: 456, height: 342) + /// Room left between the grown card and its pane's bottom-right, so the + /// terminal never looks completely papered over. + private static let hoverMargin: CGFloat = 12 + + /// Frames per second while nobody is looking. Four is enough to see that a + /// piece is moving — which is the only question a glance asks — and cheap + /// enough to leave running on every pane at once. + private static let restFPS = 4 + + /// Inset from the pane's left edge, and drop below its title bar. The card + /// parks *inside* the pane rather than over the title: the top-left of a + /// macOS window belongs to the traffic lights, and a surface that covers + /// them is a surface that breaks the window. + private static let leftInset: CGFloat = 8 + private static let titleBar: CGFloat = 30 + /// Breathing room between the title bar and the card. Parked at the title + /// bar exactly, the card reads as part of the window chrome — and on a pane + /// tiled to the top of the screen it comes out flush against the menu bar, + /// which looks like a mistake rather than a companion surface. A dozen + /// points is enough to say the card is sitting *on* the terminal. + private static let topPad: CGFloat = 12 + /// How far the hard shadow falls, right and down. Zero blur: the card is a + /// small screen resting on a terminal, and a soft aura around it is the + /// wrong physics — a crisp offset edge says the same thing at a fifth of + /// the pixels and never smears the text underneath. + private static let shadowDrop: CGFloat = 3 + private static var dropBelowTitle: CGFloat { titleBar + topPad } + + /// A radius this small reads as a cut corner rather than a rounded one, + /// which is what a screen on a desk looks like. + private static let cardRadius: CGFloat = 3 + + private let window: NSWindow + private let webView: WKWebView + private let badgeHost: NSHostingView + private let border = CALayer() + /// The card proper. The window around it is larger by `shadowDrop`. + private let card = NSView() + private let shadow = CALayer() + + /// The address currently loaded, so a `sync` that changes nothing does not + /// restart the piece — a reload is a visible flinch and a lost frame. + private var loadedURL = "" + private var expanded = false + private var state = PromptPreviewState() { didSet { if state != oldValue { redrawChrome() } } } + + /// Where the card is on screen, in AppKit coordinates. The controller's + /// global pointer monitor tests this; the window itself never takes a + /// mouse event, so a preview can never swallow a click meant for the + /// terminal underneath it. + private(set) var hitRect = NSRect.zero + + init() { + let config = WKWebViewConfiguration() + // A wall of previews must stay silent. AC's audio needs a gesture + // anyway and this window takes none, but say it rather than rely on it. + config.mediaTypesRequiringUserActionForPlayback = .all + config.suppressesIncrementalRendering = false + webView = WKWebView(frame: .zero, configuration: config) + webView.setValue(false, forKey: "drawsBackground") + webView.autoresizingMask = [.width, .height] + + badgeHost = NSHostingView(rootView: PromptPreviewBadge(state: PromptPreviewState(), + expanded: false)) + + window = NSWindow(contentRect: NSRect(origin: .zero, size: Self.restSize), + styleMask: .borderless, backing: .buffered, defer: false) + window.isOpaque = false + window.backgroundColor = .clear + // AppKit's window shadow is a soft, untunable bloom. Ours is drawn. + window.hasShadow = false + window.level = NSWindow.Level(Int(CGWindowLevelForKey(.normalWindow)) + 1) + // Click-through, like the rock's render surface: hover is discovered by + // the controller's pointer monitor, never by taking events away from + // the terminal. + window.ignoresMouseEvents = true + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] + + // The window is the card plus the room its shadow falls into, so the + // shadow is ordinary drawing rather than a compositor effect — and the + // card's own rect stays exactly what `hitRect` reports. + let content = NSView(frame: NSRect(x: 0, y: 0, + width: Self.restSize.width + Self.shadowDrop, + height: Self.restSize.height + Self.shadowDrop)) + content.wantsLayer = true + + card.frame = NSRect(origin: NSPoint(x: 0, y: Self.shadowDrop), size: Self.restSize) + card.wantsLayer = true + card.layer?.masksToBounds = true + card.layer?.cornerRadius = Self.cardRadius + card.layer?.backgroundColor = NSColor.black.cgColor + webView.frame = card.bounds + card.addSubview(webView) + + border.borderWidth = 1 + border.cornerRadius = Self.cardRadius + border.frame = card.bounds + border.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + card.layer?.addSublayer(border) + + shadow.backgroundColor = NSColor.black.withAlphaComponent(0.38).cgColor + shadow.cornerRadius = Self.cardRadius + content.layer?.addSublayer(shadow) + + card.addSubview(badgeHost) + content.addSubview(card) + window.contentView = content + layoutCard() + redrawChrome() + layoutBadge() + } + + /// Point the card at an address. `scanURL` is the bare host+path the rock + /// encodes, so the scheme and the preview's own chrome-suppressing + /// parameters are added here rather than asked of the session. + func load(scanURL: String) { + let trimmed = scanURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != loadedURL else { return } + loadedURL = trimmed + let base = trimmed.contains("://") ? trimmed : "https://" + trimmed + // `nogap` + `nolabel` strip the runtime's own frame and corner label: + // the card is small, and the piece should own all of it. (Those two + // together mean "kidlisp preview" to a runtime running *inside an + // iframe* — this is a top-level load, so that branch is not in play.) + let separator = base.contains("?") ? "&" : "?" + let url = "\(base)\(separator)nogap=true&nolabel=true&maxfps=\(Self.restFPS)" + guard let target = URL(string: url) else { return } + webView.load(URLRequest(url: target)) + } + + func setState(_ next: PromptPreviewState) { state = next } + + /// Set by the controller from the window-stack test, never from a timer. + func setPaused(_ paused: Bool) { + guard state.paused != paused else { return } + state.paused = paused + } + + /// Sample points of the card in CG screen coordinates (top-left origin), + /// for the same whole-surface ownership test the rocks use: the card is + /// hidden as a unit if any corner belongs to a window covering its pane. + /// Sampling the corners rather than the centre alone is what catches a card + /// half-slid under a neighbouring pane in a tiled grid. + var visibilityPoints: [CGPoint] { + let r = cgRect + guard !r.isEmpty else { return [] } + let inset: CGFloat = 4 + return [ + CGPoint(x: r.midX, y: r.midY), + CGPoint(x: r.minX + inset, y: r.minY + inset), + CGPoint(x: r.maxX - inset, y: r.minY + inset), + CGPoint(x: r.minX + inset, y: r.maxY - inset), + CGPoint(x: r.maxX - inset, y: r.maxY - inset), + ] + } + + /// The card in CG screen space (top-left origin) — the coordinate system + /// the window stack speaks, as opposed to `hitRect`'s AppKit one. + private(set) var cgRect = CGRect.zero + + /// Grow under the pointer and shrink when it leaves. The card keeps its + /// top-left corner, so it opens *into* the pane rather than walking across + /// the screen, and the piece reframes to the new size the way it would in + /// any resized window. + func setHovered(_ hovering: Bool) { + guard hovering != expanded else { return } + expanded = hovering + applyFrame(animated: true) + redrawChrome() + } + + /// Park the card in the pane's top-left, under the title bar. `bounds` is + /// the terminal window in CG screen space (top-left origin), matching what + /// the rock controller already hands its overlays. + func place(bounds b: (CGFloat, CGFloat, CGFloat, CGFloat), screenHeight: CGFloat) { + paneOrigin = NSPoint(x: b.0 + Self.leftInset, + y: screenHeight - (b.1 + Self.dropBelowTitle)) + paneSize = CGSize(width: b.2, height: b.3) + screenHeightForCG = screenHeight + applyFrame(animated: false) + } + + private var paneOrigin = NSPoint.zero + /// The pane this card belongs to, so a hover can size itself to the room + /// it actually has rather than to a number chosen for one window. + private var paneSize = CGSize(width: 800, height: 600) + /// Kept from the last `place` so a hover resize can recompute `cgRect` + /// without waiting for the next tick to hand the height back. + private var screenHeightForCG: CGFloat = 0 + + /// Fit the grown card inside `pane`, keeping the resting card's proportions + /// so a piece is never letterboxed by the furniture around it. + private func hoverSize(in pane: CGSize) -> CGSize { + let ratio = Self.restSize.height / Self.restSize.width + let maxW = max(Self.restSize.width, + min(Self.hoverCap.width, pane.width - Self.leftInset - Self.hoverMargin)) + let maxH = max(Self.restSize.height, + min(Self.hoverCap.height, pane.height - Self.dropBelowTitle - Self.hoverMargin)) + // Whichever axis runs out first decides; the other follows the ratio. + let width = min(maxW, maxH / ratio) + return CGSize(width: width, height: width * ratio) + } + + private func applyFrame(animated: Bool) { + let size = expanded ? hoverSize(in: paneSize) : Self.restSize + // AppKit origins are bottom-left; anchoring the card's TOP-left means + // subtracting its height, which is what keeps the corner still while + // the card grows downward. + let rect = NSRect(x: paneOrigin.x, y: paneOrigin.y - size.height, + width: size.width, height: size.height) + // The card is what everything else means by "the preview" — the pointer + // test, the ownership test, the diff crop. The window is bigger only so + // the drawn shadow has somewhere to land, and nothing outside this + // method should have to know that. + hitRect = rect + cgRect = CGRect(x: rect.minX, y: screenHeightForCG - rect.maxY, + width: rect.width, height: rect.height) + let frame = NSRect(x: rect.minX, y: rect.minY - Self.shadowDrop, + width: rect.width + Self.shadowDrop, + height: rect.height + Self.shadowDrop) + if animated { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.14 + context.timingFunction = CAMediaTimingFunction(name: .easeOut) + window.animator().setFrame(frame, display: true) + } + } else { + window.setFrame(frame, display: false) + } + layoutCard() + } + + /// Keep the card pinned to the window's top-left and the shadow one step + /// down and to the right of it. Called on every frame change because the + /// card is positioned, not autoresized — a resize that let the card drift + /// would move the piece out from under the pointer that asked for it. + private func layoutCard() { + guard let content = window.contentView else { return } + let size = NSSize(width: content.bounds.width - Self.shadowDrop, + height: content.bounds.height - Self.shadowDrop) + card.frame = NSRect(origin: NSPoint(x: 0, y: Self.shadowDrop), size: size) + webView.frame = card.bounds + CATransaction.begin() + CATransaction.setDisableActions(true) + border.frame = card.bounds + shadow.frame = NSRect(x: Self.shadowDrop, y: 0, width: size.width, height: size.height) + CATransaction.commit() + layoutBadge() + } + + private func layoutBadge() { + badgeHost.layoutSubtreeIfNeeded() + let size = badgeHost.fittingSize + let inset: CGFloat = 5 + badgeHost.frame = NSRect(x: inset, y: inset, width: size.width, height: size.height) + } + + private func redrawChrome() { + badgeHost.rootView = PromptPreviewBadge(state: state, expanded: expanded) + // The border is the at-a-glance half of the badge: readable from across + // the grid, where nine-point type is not. Quiet cards get a plain dark + // edge that reads as the card's own frame — a wall of green borders + // would be the same all-day label the chip just stopped being. + let edge = state.quiet + ? NSColor.black.withAlphaComponent(0.45) + : NSColor(state.tint).withAlphaComponent(state.paused ? 0.35 : 0.75) + border.borderColor = edge.cgColor + layoutBadge() + } + + /// Show or hide as one unit, from the same window-stack ownership test that + /// gates the rock. A card that is ordered out is also a card whose piece + /// WebKit will stop painting, so this is exactly where `paused` is known. + func setVisible(_ visible: Bool) { + if visible { + if !window.isVisible { window.orderFrontRegardless() } + } else if window.isVisible { + window.orderOut(nil) + setHovered(false) + } + } + + var isOnScreen: Bool { window.isVisible } + + func close() { + webView.stopLoading() + // Point the view at nothing before tearing down: a WKWebView left + // holding a running page keeps its content process alive past the + // window that owned it. + webView.loadHTMLString("", baseURL: nil) + window.orderOut(nil) + } +} diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index 1d0ee76af1..4ee8b89f68 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -1345,7 +1345,11 @@ final class PromptSigilOverlay { /// snaps (no spring from off-screen). No z-order touched here. func place(bounds b: (CGFloat, CGFloat, CGFloat, CGFloat), screenHeight: CGFloat) { terminalFrameCG = CGRect(x: b.0, y: b.1, width: b.2, height: b.3) - let titleBar: CGFloat = 30, rightInset: CGFloat = 10 + // 30 clears the title bar; the 12 after it is air. Parked at the bar + // exactly, the stone reads as window chrome, and on a pane tiled to the + // top of the screen it lands flush against the menu bar. Kept equal to + // PromptPreview.topPad so the rock and the card share one horizon. + let titleBar: CGFloat = 30 + 12, rightInset: CGFloat = 10 // Window is padded around the rock; shift origin by -pad so the rock // itself (centred in the window) still lands at the top-right spot. // The label strip hangs below the rock, so drop by labelH too. @@ -1964,6 +1968,16 @@ final class PromptSigilOverlayController { private var scanCodes: [String: CGImage] = [:] private var unscannableURLs: Set = [] + /// One live preview card per session that has minted a piece, parked in the + /// opposite corner from that session's rock. Keyed like `overlays` and torn + /// down by the same membership pass, so a card can never outlive the prompt + /// it belongs to. + private var previews: [String: PromptPreview] = [:] + /// The session whose card is currently grown under the pointer. Separate + /// from `hoverTarget` (the rocks') because the two corners are far apart — + /// pointing at one has nothing to say about the other. + private var previewHoverTarget: String? + /// Hover/click plumbing for the rocks. The badge windows stay /// mouse-transparent (clicks still reach the terminal beneath); GLOBAL /// event monitors watch the pointer instead, so pointing at a rock costs @@ -2182,9 +2196,42 @@ final class PromptSigilOverlayController { /// Hover is visual feedback only. Cards are deliberately click-to-open so /// moving across a wall of prompt rocks never fills the desktop with an /// unsolicited share card. + /// Raise, point and dress one session's preview card. Called from `sync`, + /// which runs several times a second — everything here is idempotent, and + /// `load` ignores an address it is already showing, so a piece is never + /// restarted by the tick that merely re-states where it lives. + private func syncPreview(for s: ClaudeSession) { + guard !s.scanURL.isEmpty else { + // The session let go of its piece (or never had one). Take the card + // away rather than leave the last frame standing as if it were current. + if let pv = previews.removeValue(forKey: s.sessionId) { + pv.close() + if previewHoverTarget == s.sessionId { previewHoverTarget = nil } + } + return + } + let pv: PromptPreview + if let existing = previews[s.sessionId] { + pv = existing + } else { + pv = PromptPreview() + previews[s.sessionId] = pv + } + pv.load(scanURL: s.scanURL) + // `paused` is not decided here — `reposition` owns it, because only the + // window-stack test knows whether these pixels are still being painted. + var next = PromptPreviewState() + next.flow = PromptFlow(s.flow) + next.working = (s.state == .working || s.state == .rendering) + next.piece = s.piece + next.paused = !pv.isOnScreen + pv.setState(next) + } + private func handleMouseMoved() { if bubble.isSharing { return } let point = NSEvent.mouseLocation + updatePreviewHover(at: point) let hit = overlayAt(point) if hit?.sessionId == hoverTarget { return } if let old = hoverTarget, let oldOv = overlays[old] { oldOv.setHovered(false) } @@ -2195,6 +2242,26 @@ final class PromptSigilOverlayController { } } + /// The card under the pointer grows; whichever grew before shrinks back. + /// Gated the same way a rock's hover is — the card floats above the whole + /// normal-window stack and takes no events of its own, so a bare rect test + /// would wake a card buried under another application. + private func updatePreviewHover(at point: NSPoint) { + let screenH = NSScreen.main?.frame.height ?? 0 + let cg = CGPoint(x: point.x, y: screenH - point.y) + var hit: String? + for (sid, pv) in previews where pv.isOnScreen && pv.hitRect.contains(point) { + guard let ov = overlays[sid], let num = binding[ov.tty] else { continue } + if let top = lastStack.first(where: { $0.rect.contains(cg) })?.num, top != num { continue } + hit = sid + break + } + guard hit != previewHoverTarget else { return } + if let old = previewHoverTarget { previews[old]?.setHovered(false) } + previewHoverTarget = hit + if let hit { previews[hit]?.setHovered(true) } + } + /// Primary AppKit interaction path. Unlike the global event monitor, this /// survives signing/TCC changes and supplies a real pointing-hand cursor. private func handleDirectHover(_ ov: PromptSigilOverlay, hovering: Bool) { @@ -2681,6 +2748,10 @@ final class PromptSigilOverlayController { var membershipChanged = false var liveScanURLs = Set() + for (sid, pv) in previews where !liveIds.contains(sid) { + pv.close(); previews.removeValue(forKey: sid) + if previewHoverTarget == sid { previewHoverTarget = nil } + } for (sid, ov) in overlays where !liveIds.contains(sid) { ov.close(); overlays.removeValue(forKey: sid); membershipChanged = true if bubbleFor == sid { @@ -2699,6 +2770,11 @@ final class PromptSigilOverlayController { let scanCode = scanCode(for: s) let scanSurface = scanCode != nil if scanSurface { liveScanURLs.insert(s.scanURL) } + // The card shows the same address the code encodes. It follows the + // scan URL rather than the code's renderability: a destination too + // long to fit in a QR is still a piece worth watching, so the rock + // keeps its stone while this corner keeps the preview. + syncPreview(for: s) defer { if scanSurface { overlays[s.sessionId]?.scanURL = s.scanURL } } let ov: PromptSigilOverlay if let existing = overlays[s.sessionId], existing.tty == bare, @@ -2857,6 +2933,9 @@ final class PromptSigilOverlayController { timer?.invalidate(); timer = nil for (_, ov) in overlays { ov.close() } overlays.removeAll() + for (_, pv) in previews { pv.close() } + previews.removeAll() + previewHoverTarget = nil binding.removeAll() particleColors.removeAll() scanCodes.removeAll() @@ -2990,6 +3069,18 @@ final class PromptSigilOverlayController { heartbeat: ownedBy(num, at: points.heartbeat), platformTarget: ownedBy(num, at: points.platformTarget)) if !rockVisible { dropInteraction(for: ov) } + if let pv = previews[ov.sessionId] { + pv.place(bounds: b, screenHeight: screenH) + // A covered card is a card WebKit has stopped painting, so the + // same test that hides it is the one that knows its pixels have + // gone stale. Said out loud on the badge: a frozen frame and a + // live one are otherwise identical, and that is the one lie a + // preview must never tell. + let visible = ownedBy(num, at: pv.visibilityPoints) + pv.setVisible(visible) + pv.setPaused(!visible) + if !visible, previewHoverTarget == ov.sessionId { previewHoverTarget = nil } + } seen[num] = b if let prev = lastBoundsByNum[num], prev != b { // A tracked window moved/resized — hold display rate for a short