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 + } +}