From fe38d7155633fcb3136d82095e619cd576598b8e Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Mon, 28 Sep 2026 16:37:55 -0700 Subject: [PATCH] Aesel 0.8.16: a pointing hand over what you can click Terminal.app ignores OSC 22, so Slab sets the cursor for Aesel. When the thing under the mouse becomes clickable (a picker row, the model or provider on the status line, the update banner, the handle) the TUI tells Slab hand or arrow over ~/.local/share/slab/state/cursor.sock, once per change with a 2s heartbeat while it holds. Slab shows the pointing hand only over that session's Terminal window while Terminal is in front (SetsCursorInBackground), re-asserts it on mouse moves, and reverts on arrow, on leaving the window, on the session closing, or when the heartbeat stops. No windows, so clicks are untouched. Also: the desktop path referenced a modelCatalog that no longer existed (since 45e2aa1d0b) and threw on redraw; it reads the loaded catalog. Co-Authored-By: Claude Opus 5.5 --- aesel/package.json | 2 +- aesel/src/slab-session.mjs | 39 ++++ aesel/src/tui.mjs | 6 +- aesel/test/slab-session.test.mjs | 41 ++++ .../Sources/SlabMenubar/AppDelegate.swift | 4 + .../SlabMenubar/PromptSigilOverlay.swift | 1 + .../Sources/SlabMenubar/TerminalCursor.swift | 177 ++++++++++++++++++ 7 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift diff --git a/aesel/package.json b/aesel/package.json index a7006fece5..b3b41c432b 100644 --- a/aesel/package.json +++ b/aesel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.8.15", + "version": "0.8.16", "private": true, "type": "module", "scripts": { diff --git a/aesel/src/slab-session.mjs b/aesel/src/slab-session.mjs index 5c818c99c1..83482e6eb8 100644 --- a/aesel/src/slab-session.mjs +++ b/aesel/src/slab-session.mjs @@ -7,6 +7,7 @@ import { writeFileSync, } from "node:fs"; import { execFileSync } from "node:child_process"; +import { createConnection } from "node:net"; import { randomUUID } from "node:crypto"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; @@ -57,6 +58,11 @@ export class SlabSession { this.running = join(this.stateDir, "running-tools", sessionId); this.enabled = false; this.heartbeat = null; + this.cursorSocket = join(this.stateDir, "cursor.sock"); + this.shape = "arrow"; + this.cursor = null; + this.cursorBeat = null; + this.cursorQuietUntil = 0; this.record = { session_id: sessionId, cwd, @@ -205,7 +211,40 @@ export class SlabSession { this.#update({ state: "interrupted" }); } + // Terminal.app ignores OSC 22, so the menubar sets the pointing hand on our + // behalf: one line down cursor.sock each time the shape under the mouse + // changes, and a beat every two seconds while it is a hand so a menubar that + // stops hearing from us lets go. Nobody listening costs one failed connect. + pointer(shape = "arrow") { + const clean = shape === "hand" ? "hand" : "arrow"; + if (this.shape === clean) return; + this.shape = clean; + clearInterval(this.cursorBeat); + this.cursorBeat = null; + this.#sendPointer(); + if (clean === "hand") { + this.cursorBeat = setInterval(() => this.#sendPointer(), 2_000); + this.cursorBeat.unref(); + } + } + + #sendPointer() { + if (!this.enabled) return; + if (!this.cursor) { + if (Date.now() < this.cursorQuietUntil) return; + const socket = createConnection(this.cursorSocket); + socket.unref(); + socket.on("error", () => { this.cursorQuietUntil = Date.now() + 10_000; }); + socket.on("close", () => { if (this.cursor === socket) this.cursor = null; }); + this.cursor = socket; + } + this.cursor.write(`${JSON.stringify({ cursor: this.shape, session: this.sessionId, tty: this.tty })}\n`); + } + close() { + this.pointer("arrow"); + this.cursor?.end(); + this.cursor = null; this.#stopHeartbeat(); this.#remove(this.active); if(this.fleetActive)this.#remove(this.fleetActive); diff --git a/aesel/src/tui.mjs b/aesel/src/tui.mjs index 21a6576966..e0d17064b4 100755 --- a/aesel/src/tui.mjs +++ b/aesel/src/tui.mjs @@ -980,7 +980,7 @@ function redraw() { if(historyKey!==desktopHistoryKey){desktopHistoryKey=historyKey;try{desktopHistory=live.history.list().map(({version,updatedAt,restoredFrom,summary})=>({version,updatedAt,restoredFrom,summary}));}catch{desktopHistory=[];}} const bindingKey=state.medium==='piece'?`${live.file}:${live.revision?.revision||''}`:''; if(bindingKey!==bindingSnapshotKey){bindingSnapshotKey=bindingKey;bindingSnapshot=state.medium==='piece'&&live.revision?.source?notebookBindings(live.revision.source,live.file):{revision:'',bindings:[]};} - const provider = JSON.stringify({status:state.status,mode:state.mode,activity:publicActivity(state),notice:state.entries.findLast(e=>e.kind==='notice')?.text||'',feedPending:!!state.feedPending,notebookBindings:bindingSnapshot,backend:backend.id,model:state.model||model,effort,busy:state.busy,selectedModel:model,models:pickerModels({backend:backend.id,model,catalog:modelCatalog||[]}),versions:desktopHistory}); + const provider = JSON.stringify({status:state.status,mode:state.mode,activity:publicActivity(state),notice:state.entries.findLast(e=>e.kind==='notice')?.text||'',feedPending:!!state.feedPending,notebookBindings:bindingSnapshot,backend:backend.id,model:state.model||model,effort,busy:state.busy,selectedModel:model,models:pickerModels({backend:backend.id,model,catalog:catalogFor(backend.id)||[]}),versions:desktopHistory}); if (provider !== lastProvider) { lastProvider = provider; process.stdout.write(`\x1b]777;easel-provider:${provider}\x07`); } } if (closing || drawing || splashing) return; @@ -2233,6 +2233,7 @@ async function submitInput(submittedText, submittedMessages = null) { // the way this one was left. if (pro) { try { shape.set("mouse", mouseEnabled ? "on" : "off"); } catch {} } state.hover = ""; + slabSession.pointer("arrow"); addEntry("notice", `Mouse ${mouseEnabled ? "on · clicks reach the bottom line · ⌥-drag or shift-drag selects text" : "off · drag selects text · /provider and /model open the lists"}`); return redraw(); } @@ -2887,6 +2888,9 @@ function handleKeys(buffer) { // ignores it, which costs nothing. if (!process.env.AESEL_DESKTOP) process.stdout.write(`\x1b]22;${action ? "pointer" : "default"}\x07`); if (desktopSessionPath) process.stdout.write(`\x1b]777;easel-pointer:${action}\x07`); + // ...so the menubar sets the hand for it. "dismiss" is a click + // anywhere outside an open list: not a thing to point at. + else slabSession.pointer(action && action !== "dismiss" ? "hand" : "arrow"); redraw(); } if (mouse.click && action === "about") { if (desktopSessionPath) void requestDesktop("home"); else { state.about = !state.about; state.aboutScroll = 0; redraw(); } } diff --git a/aesel/test/slab-session.test.mjs b/aesel/test/slab-session.test.mjs index 3cf756c1ea..355c838036 100644 --- a/aesel/test/slab-session.test.mjs +++ b/aesel/test/slab-session.test.mjs @@ -86,3 +86,44 @@ test("a private marker never carries the prompt, and pro and the inbox socket ar session.close(); }); + +test("tells the menubar when the pointer should be a hand", async (context) => { + const { createServer } = await import("node:net"); + const { mkdir } = await import("node:fs/promises"); + const root = await mkdtemp(join(tmpdir(), "aesel-cursor-")); + context.after(() => rm(root, { recursive: true, force: true })); + await mkdir(join(root, "state"), { recursive: true }); + const lines = []; + let closed; + const hung = new Promise((resolve) => { closed = resolve; }); + const server = createServer((client) => { + let buffer = ""; + client.on("data", (chunk) => { + buffer += chunk; + for (let i; (i = buffer.indexOf("\n")) >= 0; buffer = buffer.slice(i + 1)) lines.push(JSON.parse(buffer.slice(0, i))); + }); + client.on("close", closed); + }); + await new Promise((resolve) => server.listen(join(root, "state", "cursor.sock"), resolve)); + context.after(() => server.close()); + const session = new SlabSession({ cwd: "/project", tty: "ttys099", sessionId: "ac-session", slabHome: root }); + session.start(); + session.pointer("hand"); + session.pointer("hand"); // unchanged: not sent again + session.close(); + await hung; + assert.deepEqual(lines, [ + { cursor: "hand", session: "ac-session", tty: "ttys099" }, + { cursor: "arrow", session: "ac-session", tty: "ttys099" }, + ]); +}); + +test("a pointer with nobody listening stays quiet", async (context) => { + const root = await mkdtemp(join(tmpdir(), "aesel-no-slab-")); + context.after(() => rm(root, { recursive: true, force: true })); + const session = new SlabSession({ cwd: "/project", tty: "ttys099", sessionId: "nobody", slabHome: root }); + session.start(); + session.pointer("hand"); + session.pointer("arrow"); + session.close(); +}); diff --git a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift index df4e409cc3..bd6527c947 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift @@ -435,6 +435,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { // requested, so no Screen Recording prompt at launch (lazy grant). FrameCapture.shared.start() + // Aesel TUIs in Terminal ask for the pointing hand over what they can + // click; Terminal.app won't draw it from an escape, so we do. + TerminalCursor.shared.start() + // Game Mode watches for GeForce NOW launching/quitting. Opt-in: with // "Auto" off this observes and does nothing, so the wiring is the // same either way and costs a couple of workspace notifications. diff --git a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift index 1700c11296..14eee23284 100644 --- a/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift +++ b/slab/menubar-swift/Sources/SlabMenubar/PromptSigilOverlay.swift @@ -2224,6 +2224,7 @@ final class PromptSigilOverlayController { /// Current live prox windows, shared with focus navigation/highlighting so /// those features inherit the controller's tty-accurate Terminal binding. var promptWindowIDs: Set { Set(binding.values) } + func terminalWindowID(tty: String) -> Int? { binding[(tty as NSString).lastPathComponent] } var promptParticleTargets: [PromptParticleTarget] { particleColors.compactMap { tty, color in guard let id = binding[tty], let b = lastBoundsByNum[id] else { return nil } diff --git a/slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift b/slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift new file mode 100644 index 0000000000..73968adb69 --- /dev/null +++ b/slab/menubar-swift/Sources/SlabMenubar/TerminalCursor.swift @@ -0,0 +1,177 @@ +import AppKit +import Darwin + +// Private CoreGraphics. With SetsCursorInBackground on our connection, an +// accessory app's NSCursor.set() reaches the screen while Terminal is in +// front; without it the set is silently dropped. +@_silgen_name("_CGSDefaultConnection") +private func _CGSDefaultConnection() -> Int32 +@_silgen_name("CGSSetConnectionProperty") +private func CGSSetConnectionProperty(_ cid: Int32, _ target: Int32, _ key: CFString, _ value: CFTypeRef) -> Int32 + +/// Terminal.app ignores OSC 22, so an Aesel TUI with something clickable under +/// the mouse says so down cursor.sock and Slab shows the pointing hand for it. +/// One JSON line per change — {"cursor":"hand"|"arrow","session":…,"tty":…} — +/// plus a two-second beat while it is a hand. A stream socket rather than +/// datagrams: node has no unix datagrams, and a hung-up connection is the +/// quickest way to hear that a session is gone. +/// +/// No window of ours ever sits over the terminal (an overlay once stole the +/// clicks it was decorating). Terminal puts its own cursor back on every +/// mouse move, so the hand is set again after each move instead, and only +/// while the pointer is over that session's window with Terminal in front. +final class TerminalCursor { + static let shared = TerminalCursor() + private init() {} + + private struct Hand { let session: String; let tty: String; var heard: Date } + private var hands: [Int: Hand] = [:] // by connection + private var showing = false + private var over: Int? // connection whose window holds the pointer + private var checked = Date.distantPast + private var monitor: Any? + private var watchdog: Timer? + private var background = false + private var nextClient = 0 + private let queue = DispatchQueue(label: "computer.slab.cursor.socket") + private static let terminals: Set = ["com.apple.Terminal", "com.googlecode.iterm2"] + + func start() { + let path = "\(Paths.slabHome)/state/cursor.sock" + try? FileManager.default.createDirectory(atPath: (path as NSString).deletingLastPathComponent, + withIntermediateDirectories: true) + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { return } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + guard bytes.count < MemoryLayout.size(ofValue: address.sun_path) else { close(fd); return } + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes + [0]) } + unlink(path) + let bound = withUnsafePointer(to: &address) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + Darwin.bind(fd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0, listen(fd, 16) == 0 else { + NSLog("cursor: socket unavailable (errno %d)", errno); close(fd); return + } + chmod(path, 0o600) + queue.async { [self] in + while true { + let client = accept(fd, nil, nil) + if client < 0 { if errno == EINTR { continue }; break } + nextClient += 1 + let id = nextClient + DispatchQueue.global(qos: .userInitiated).async { self.serve(client, id: id) } + } + } + } + + // One connection per TUI, held for its life; blocking reads are fine for + // the handful of sessions a desk ever has open. + private func serve(_ fd: Int32, id: Int) { + var uid: uid_t = 0, gid: gid_t = 0 + var one: Int32 = 1 + setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout.size)) + if getpeereid(fd, &uid, &gid) == 0, uid == getuid() { + var pending = [UInt8](), buffer = [UInt8](repeating: 0, count: 512) + reading: while pending.count < 4096 { + let n = read(fd, &buffer, buffer.count) + if n < 0 && errno == EINTR { continue } + guard n > 0 else { break reading } + pending += buffer.prefix(n) + while let end = pending.firstIndex(of: 10) { + let line = Data(pending[.. 6 { self.drop(id) } + } + } + } + + private func unwatch() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + watchdog?.invalidate() + watchdog = nil + } + + private func assertHand(recheck: Bool = true) { + if recheck, Date().timeIntervalSince(checked) > 0.02 { + checked = Date() + over = claimUnderPointer() + } + if let id = over, let hand = hands[id] { + if !background { + background = true + _ = CGSSetConnectionProperty(_CGSDefaultConnection(), _CGSDefaultConnection(), + "SetsCursorInBackground" as CFString, kCFBooleanTrue) + } + NSCursor.pointingHand.set() + if !showing { showing = true; NSLog("🖐 cursor: hand over %@ (%@)", hand.tty, hand.session) } + } else if showing { + showing = false + NSCursor.arrow.set() + NSLog("🖐 cursor: arrow") + } + } + + /// The hand whose Terminal window is the topmost window under the pointer, + /// with Terminal frontmost (a background terminal reports no motion, so its + /// hover could be stale). + private func claimUnderPointer() -> Int? { + guard !hands.isEmpty, + Self.terminals.contains(NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "") + else { return nil } + let point = NSEvent.mouseLocation + let ours = Set(NSApp.windows.map(\.windowNumber)) + var below = 0, number = 0 + for _ in 0..<8 { + number = NSWindow.windowNumber(at: point, belowWindowWithWindowNumber: below) + guard ours.contains(number), number > 0 else { break } + below = number + } + guard number > 0 else { return nil } + return hands.first { PromptSigilOverlayController.shared.terminalWindowID(tty: $0.value.tty) == number }?.key + } +} -- 2.51.2