From abfb1a53cfb36ed318b328ec240675584724e9fd Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sun, 13 Sep 2026 08:31:12 -0400 Subject: [PATCH] easel: a camera that can see the furniture it is adjusting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The QR, the live card of the piece and the status stone are Slab overlays parked on the terminal, not anything Easel draws — and `frame` filters Slab's own windows out of every capture, because its usual job is reading the machine underneath them. So every shot taken to judge the card's padding showed the terminal where the card is. Four rounds of "flush against the menu bar" were argued from pictures with no overlay in them. `--overlays` opts out of that exclusion, and widens a window shot to a padded crop so the menu bar it is said to be flush against is in the same picture. The default is unchanged: a frame is still the machine's real content. With the overlays visible, the adjustments they were waiting on: · twelve points of air below the title bar, for the rock and the card alike, so neither reads as window chrome · a drawn shadow instead of AppKit's — three points right and down, no blur. A soft aura is the wrong physics for a small screen resting on a terminal · corner radius 5 → 3, a cut corner rather than a rounded one · the card down from 152×114 to 128×96 And `easel/bin/design-loop.mjs`, which is the whole cycle in one command: close the Easel session, open a fresh one, wait for its overlays to land, photograph them. Fresh because overlays are placed once, when a window appears — editing the placement and reinstalling the menubar does not move what is already on screen, so the only honest check is a session that has never seen the old numbers. Co-Authored-By: Claude Opus 5 (1M context) --- easel/bin/design-loop.mjs | 215 +++++++++ easel/src/live.mjs | 12 + easel/src/slab-session.mjs | 18 + slab/bin/frame-mcp.mjs | 4 +- slab/bin/frame.mjs | 9 +- .../Sources/SlabMenubar/ClaudeSession.swift | 7 + .../Sources/SlabMenubar/FrameCapture.swift | 43 +- .../Sources/SlabMenubar/PromptPreview.swift | 428 ++++++++++++++++++ .../SlabMenubar/PromptSigilOverlay.swift | 93 +++- 9 files changed, 814 insertions(+), 15 deletions(-) create mode 100755 easel/bin/design-loop.mjs create mode 100644 slab/menubar-swift/Sources/SlabMenubar/PromptPreview.swift 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 -- 2.51.2