diff --git a/Sources/Simpleton/AI/AIKeychain.swift b/Sources/Simpleton/AI/AIKeychain.swift index 6858133..ae5f12e 100644 --- a/Sources/Simpleton/AI/AIKeychain.swift +++ b/Sources/Simpleton/AI/AIKeychain.swift @@ -8,6 +8,30 @@ enum AIKeychain { private static let service = "com.simpleton.ai" + /// Process-lifetime cache of retrieved keys, so the secret is read from the Keychain at most once + /// per provider per launch. Reading the data (`retrieveAPIKey`) is the only call that can raise + /// the macOS "allow access" prompt, and it fires on every AI-preferences open (autoLoadModels) and + /// every request. Without this cache the user is prompted repeatedly; with it, once the key is in + /// memory (from the first read, a save, or the last launch's "Always Allow"), no further reads — + /// and no further prompts — happen for the rest of the session. + private static var memoryCache: [String: String] = [:] + private static let cacheLock = NSLock() + private static func cacheStore(_ key: String, account: String) { + cacheLock.lock() + memoryCache[account] = key + cacheLock.unlock() + } + private static func cacheLookup(_ account: String) -> String? { + cacheLock.lock() + defer { cacheLock.unlock() } + return memoryCache[account] + } + private static func cacheClear(_ account: String) { + cacheLock.lock() + memoryCache[account] = nil + cacheLock.unlock() + } + static func storeAPIKey(_ key: String, for provider: AIProvider) -> Bool { let account = "apiKey.\(provider.rawValue)" guard let data = key.data(using: .utf8) else { return false } @@ -26,18 +50,28 @@ enum AIKeychain { // subject to that owner check, so overwrites succeed reliably. (See TN3137.) let updateStatus = SecItemUpdate( query as CFDictionary, [kSecValueData as String: data] as CFDictionary) - if updateStatus == errSecSuccess { return true } + if updateStatus == errSecSuccess { + cacheStore(key, account: account) + return true + } if updateStatus == errSecItemNotFound { var addQuery = query addQuery[kSecValueData as String] = data addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess + if SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess { + cacheStore(key, account: account) + return true + } + return false } return false } static func retrieveAPIKey(for provider: AIProvider) -> String? { let account = "apiKey.\(provider.rawValue)" + // Serve from the in-memory cache first — this is what turns "prompt every time AI prefs open" + // into "prompt at most once per launch" (and none at all once a grant persists). + if let cached = cacheLookup(account) { return cached } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, @@ -47,9 +81,11 @@ enum AIKeychain { ] var result: AnyObject? guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data + let data = result as? Data, + let key = String(data: data, encoding: .utf8) else { return nil } - return String(data: data, encoding: .utf8) + cacheStore(key, account: account) + return key } static func deleteAPIKey(for provider: AIProvider) { @@ -60,6 +96,7 @@ enum AIKeychain { kSecAttrAccount as String: account, ] SecItemDelete(query as CFDictionary) + cacheClear(account) } /// Migrate an existing key to AfterFirstUnlock accessibility — at most once per provider. diff --git a/Sources/Simpleton/AI/TabConversation.swift b/Sources/Simpleton/AI/TabConversation.swift index 8915d45..6c1614d 100644 --- a/Sources/Simpleton/AI/TabConversation.swift +++ b/Sources/Simpleton/AI/TabConversation.swift @@ -28,6 +28,20 @@ final class TabConversation: ObservableObject { self.splitController = splitController self.aiService = aiService rebuildPaneLabels() + // Screenshot demo mode: seed a short sample conversation so the AI panel shows a real exchange + // in captures (no live provider call needed). Gated by env; off in normal use. + if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil { + messages = [ + ChatMessage( + role: "user", content: "What's listening on port 8080 and how do I free it?"), + ChatMessage( + role: "assistant", + content: + "`lsof -i :8080` shows **node** (PID 588) holding it. Free it with `kill -9 588`, " + + "or start your dev server elsewhere: `PORT=8081 npm run dev`. Want me to run the kill?" + ), + ] + } } // MARK: - Pane Labels diff --git a/Sources/Simpleton/AppDelegate.swift b/Sources/Simpleton/AppDelegate.swift index f10cfc1..ac9e678 100644 --- a/Sources/Simpleton/AppDelegate.swift +++ b/Sources/Simpleton/AppDelegate.swift @@ -190,6 +190,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { } self.panelRegistry = panelRegistry + // Screenshot harness: `--shot-profile ` activates a panel profile at launch (the active + // profile is otherwise in-memory only and resets to General each launch). No-op without it. + if let i = CommandLine.arguments.firstIndex(of: "--shot-profile"), + i + 1 < CommandLine.arguments.count, + var profile = panelRegistry.profiles.first(where: { $0.name == CommandLine.arguments[i + 1] }) + { + // `--shot-active-panel ` starts that left panel active (its onAppear auto-loads the + // content) so each panel can be captured with a plain relaunch — no runtime AX clicking. + if let j = CommandLine.arguments.firstIndex(of: "--shot-active-panel"), + j + 1 < CommandLine.arguments.count + { + profile.leftActivePanelID = CommandLine.arguments[j + 1] + } + panelRegistry.activeProfile = profile + } + // Fire startup event pluginManager?.fireEvent( .onStartup, @@ -475,6 +491,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { wc.showWindow(nil) NSApp.activate(ignoringOtherApps: true) + maybeLaunchFullScreenForScreenshots(wc.window) + // Focus the terminal in the new window's active tab. if let tabContainer = wc.tabManager.activeContainer { wc.window?.makeFirstResponder( @@ -484,6 +502,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { } + /// Screenshot harness only: when launched with `--shot-fullscreen`, pin the window to the built-in + /// Retina display and enter native fullscreen. Driving fullscreen in-process is reliable (over + /// Accessibility it is not, and on a multi-display setup the window otherwise opens on a secondary + /// display's inactive Space where it can't be captured cleanly). No-op without the flag. + private func maybeLaunchFullScreenForScreenshots(_ window: NSWindow?) { + guard CommandLine.arguments.contains("--shot-fullscreen"), let window else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + if let builtin = NSScreen.screens.first(where: { screen in + (screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID) + .map { CGDisplayIsBuiltin($0) != 0 } ?? false + }) { + window.setFrame(builtin.frame, display: true) + } + if !window.styleMask.contains(.fullScreen) { + window.toggleFullScreen(nil) + } + } + } + @objc private func windowClosed(_ notification: Notification) { guard let wc = notification.object as? WindowController else { return } windowControllers.removeAll { $0 === wc } @@ -503,6 +540,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { activeWindowController?.tabManager.activeContainer?.splitController } + /// The primary terminal window used to anchor floating panels (command palette, quick connect). + /// Prefer the app's main window — it stays the terminal even when a nonactivating panel is key — + /// then any ordered terminal window. Never returns a panel, so a panel is never parented to a panel + /// (which would hide the second panel the moment the first one dismisses). A Simpleton terminal + /// window is identified by `activeTabContainer != nil` (its content VC is a swappable host, not the + /// container itself, so `contentViewController is TabContainerController` no longer holds). + private var terminalWindow: NSWindow? { + if let main = NSApp.mainWindow, main.activeTabContainer != nil { + return main + } + let candidates = NSApp.orderedWindows + windowControllers.compactMap { $0.window } + return candidates.first { $0.activeTabContainer != nil } + } + // MARK: - Config private func loadConfig() { @@ -572,9 +623,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { quickConnectPanel?.dismiss() return } - // Capture the terminal window BEFORE showing the panel, because the panel - // becomes key and NSApp.keyWindow would then point to the panel itself. - let parentWindow = NSApp.keyWindow + // Anchor to the real terminal window — never whatever is key (a command palette could be key + // here), so the panel parents to the terminal and positions over it instead of over a panel. + let parentWindow = terminalWindow // Reuse the existing panel instance; do NOT recreate it here. quickConnectPanel?.show(relativeTo: parentWindow) { [weak self] bookmark in self?.connectToBookmark(bookmark, in: parentWindow) @@ -589,7 +640,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { return } let actions = buildPaletteActions() - commandPalettePanel?.show(relativeTo: NSApp.keyWindow, actions: actions) + commandPalettePanel?.show(relativeTo: terminalWindow, actions: actions) } private func buildPaletteActions() -> [PaletteAction] { diff --git a/Sources/Simpleton/Panels/ActivityBarView.swift b/Sources/Simpleton/Panels/ActivityBarView.swift index a65c2e4..cb7f6cd 100644 --- a/Sources/Simpleton/Panels/ActivityBarView.swift +++ b/Sources/Simpleton/Panels/ActivityBarView.swift @@ -37,6 +37,7 @@ struct ActivityBarView: View { ) { togglePanel(id: panelID) } + .accessibilityIdentifier("panel-\(panelID)") .onDrag { NSItemProvider(object: panelID as NSString) } } } diff --git a/Sources/Simpleton/Panels/FileBrowserPanelView.swift b/Sources/Simpleton/Panels/FileBrowserPanelView.swift index 3b33c6a..77fecab 100644 --- a/Sources/Simpleton/Panels/FileBrowserPanelView.swift +++ b/Sources/Simpleton/Panels/FileBrowserPanelView.swift @@ -88,6 +88,13 @@ struct FileBrowserPanelView: View { } private func syncCWD() { + // Screenshot demo mode: browse a fixed demo project instead of the user's real home/cwd + // (which would leak personal folders). Gated by env; off in normal use. + if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil { + currentURL = URL(fileURLWithPath: "/tmp/simpleton-demo-fs/webapp") + loadEntries() + return + } if let cwd = currentPaneProvider()?.currentDirectory { currentURL = URL(fileURLWithPath: cwd) } diff --git a/Sources/Simpleton/Panels/ProcessesPanelView.swift b/Sources/Simpleton/Panels/ProcessesPanelView.swift index 3db4363..2742b2b 100644 --- a/Sources/Simpleton/Panels/ProcessesPanelView.swift +++ b/Sources/Simpleton/Panels/ProcessesPanelView.swift @@ -77,6 +77,20 @@ struct ProcessesPanelView: View { } private func fetchProcesses() async -> [ProcessEntry] { + // Screenshot demo mode: a fixed, fake process list instead of the machine's real processes + // (which would leak what apps the user runs). Gated by env; off in normal use. + if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil { + return [ + ProcessEntry(id: 412, pid: 412, cpu: 2.4, mem: 1.1, command: "zsh"), + ProcessEntry(id: 588, pid: 588, cpu: 8.1, mem: 3.4, command: "node"), + ProcessEntry(id: 731, pid: 731, cpu: 0.6, mem: 2.2, command: "docker"), + ProcessEntry(id: 902, pid: 902, cpu: 1.3, mem: 0.9, command: "postgres"), + ProcessEntry(id: 1044, pid: 1044, cpu: 0.2, mem: 0.5, command: "ssh"), + ProcessEntry(id: 1210, pid: 1210, cpu: 12.5, mem: 6.7, command: "Simpleton"), + ProcessEntry(id: 1355, pid: 1355, cpu: 0.1, mem: 0.3, command: "git"), + ProcessEntry(id: 1489, pid: 1489, cpu: 3.0, mem: 1.8, command: "nvim"), + ] + } // Use -U to filter to current user directly, avoiding per-process sysctl calls let currentUser = NSUserName() // Run the process + waitUntilExit off the MainActor so a slow `ps` can't diff --git a/Sources/Simpleton/Panels/SSHTunnelsPanelView.swift b/Sources/Simpleton/Panels/SSHTunnelsPanelView.swift index da280f1..e63d0e9 100644 --- a/Sources/Simpleton/Panels/SSHTunnelsPanelView.swift +++ b/Sources/Simpleton/Panels/SSHTunnelsPanelView.swift @@ -76,6 +76,17 @@ struct SSHTunnelsPanelView: View { } private func loadTunnels() { + // Screenshot demo mode: show sample active tunnels (real sessions here are local, so the + // panel would otherwise be empty). Gated by env; off in normal use. + if ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil { + tunnels = [ + TunnelEntry(localPort: 8080, remoteHost: "localhost", remotePort: 80, paneName: "prod-web"), + TunnelEntry(localPort: 5432, remoteHost: "db.internal", remotePort: 5432, paneName: "db-primary"), + TunnelEntry( + localPort: 9090, remoteHost: "grafana.internal", remotePort: 3000, paneName: "staging-api"), + ] + return + } guard let panes = tabContainerProvider()?.splitController.panes else { tunnels = [] return diff --git a/Sources/Simpleton/Views/AIChatPanel.swift b/Sources/Simpleton/Views/AIChatPanel.swift index 397e665..8972f79 100644 --- a/Sources/Simpleton/Views/AIChatPanel.swift +++ b/Sources/Simpleton/Views/AIChatPanel.swift @@ -236,10 +236,27 @@ struct AIChatPanelView: View { // the conversation the source of truth and preventing one tab's messages from leaking // into another. .onAppear { - guard let conv = conversation else { return } - messages = conv.messages - agentBubbles = conv.agentBubbles - isStreaming = conv.isRunning + if let conv = conversation { + messages = conv.messages + agentBubbles = conv.agentBubbles + isStreaming = conv.isRunning + } + // Screenshot demo mode: seed a sample exchange at the display layer so the AI panel shows + // a real conversation in captures (no live provider call). Runs even if no conversation is + // attached yet. Gated by env; off in normal use. + if messages.isEmpty, ProcessInfo.processInfo.environment["SIMPLETON_SHOT_DEMO"] != nil { + messages = [ + ChatMessage( + role: "user", content: "What's listening on port 8080 and how do I free it?"), + ChatMessage( + role: "assistant", + content: + "`lsof -i :8080` shows **node** (PID 588) holding it. Free it with `kill -9 588`, " + + "or start your dev server elsewhere: `PORT=8081 npm run dev`. Want me to run the kill?" + ), + ] + conversation?.messages = messages + } } .onChange(of: messages) { _, v in conversation?.messages = v } .onChange(of: agentBubbles.count) { _, _ in conversation?.agentBubbles = agentBubbles } diff --git a/Sources/Simpleton/Views/CommandPalettePanel.swift b/Sources/Simpleton/Views/CommandPalettePanel.swift index 483a277..aaa928a 100644 --- a/Sources/Simpleton/Views/CommandPalettePanel.swift +++ b/Sources/Simpleton/Views/CommandPalettePanel.swift @@ -33,7 +33,10 @@ final class CommandPalettePanel { defer: false ) newPanel.isFloatingPanel = true - newPanel.level = .floating + // Normal level (not .floating): the panel is attached as a child of the terminal window in + // show(), so it lives in the parent's z-order band. A .floating level would keep it above + // every app — even when the terminal window is sent behind another app (the reported bug). + newPanel.level = .normal newPanel.titleVisibility = .hidden newPanel.titlebarAppearsTransparent = true newPanel.backgroundColor = .clear @@ -70,6 +73,9 @@ final class CommandPalettePanel { // key window, so window-targeting commands (Split, New Tab, Close Pane, Toggle // Sidebar) resolved no active window and silently no-op'd — only the connection // commands, which open their own panels, appeared to work. + if let panel = self?.panel, let parent = panel.parent { + parent.removeChildWindow(panel) + } self?.panel?.orderOut(nil) self?.panel?.alphaValue = 1 self?.parentWindow?.makeKey() @@ -84,6 +90,9 @@ final class CommandPalettePanel { if let window = window { let windowFrame = window.frame panel.setFrameOrigin(NSPoint(x: windowFrame.midX - 260, y: windowFrame.midY + 50)) + // Attach to the terminal window so the panel tracks its z-order: sent behind another app + // with the parent, never floating on top of everything. + if panel.parent == nil { window.addChildWindow(panel, ordered: .above) } } else { panel.center() } @@ -102,6 +111,9 @@ final class CommandPalettePanel { panel.animator().alphaValue = 0 }, completionHandler: { [weak panel] in + if let panel = panel, let parent = panel.parent { + parent.removeChildWindow(panel) + } panel?.orderOut(nil) panel?.alphaValue = 1 }) diff --git a/Sources/Simpleton/Views/QuickConnectPanel.swift b/Sources/Simpleton/Views/QuickConnectPanel.swift index 3020eda..9cc24bb 100644 --- a/Sources/Simpleton/Views/QuickConnectPanel.swift +++ b/Sources/Simpleton/Views/QuickConnectPanel.swift @@ -28,7 +28,10 @@ final class QuickConnectPanel { defer: false ) newPanel.isFloatingPanel = true - newPanel.level = .floating + // Normal level (not .floating): the panel is attached as a child of the terminal window in + // show(), so it lives in the parent's z-order band. A .floating level would keep it above + // every app — even when the terminal window is sent behind another app (the reported bug). + newPanel.level = .normal newPanel.titleVisibility = .hidden newPanel.titlebarAppearsTransparent = true newPanel.isMovableByWindowBackground = false @@ -73,6 +76,9 @@ final class QuickConnectPanel { let x = windowFrame.midX - 260 let y = windowFrame.midY + 50 panel.setFrameOrigin(NSPoint(x: x, y: y)) + // Attach to the terminal window so the panel tracks its z-order: sent behind another app + // with the parent, never floating on top of everything. + if panel.parent == nil { window.addChildWindow(panel, ordered: .above) } } else { panel.center() } @@ -91,6 +97,9 @@ final class QuickConnectPanel { panel.animator().alphaValue = 0 }, completionHandler: { [weak panel] in + if let panel = panel, let parent = panel.parent { + parent.removeChildWindow(panel) + } panel?.orderOut(nil) panel?.alphaValue = 1 }) diff --git a/Sources/Simpleton/Views/SkillsPreferencesTab.swift b/Sources/Simpleton/Views/SkillsPreferencesTab.swift index cab5d6e..6f8a320 100644 --- a/Sources/Simpleton/Views/SkillsPreferencesTab.swift +++ b/Sources/Simpleton/Views/SkillsPreferencesTab.swift @@ -142,6 +142,7 @@ struct SkillsPreferencesTab: View { .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityIdentifier("skill-\(skill.name)") } private func newSkill() { diff --git a/scripts/e2e/axdriver.swift b/scripts/e2e/axdriver.swift index 1eea89f..8806847 100644 --- a/scripts/e2e/axdriver.swift +++ b/scripts/e2e/axdriver.swift @@ -9,9 +9,11 @@ // axdriver tree [maxDepth] # dump the AX tree (role #identifier "title" =value) // axdriver raise # raise the app's front window + activate // axdriver windowid # print the front window's CGWindowID +// axdriver bounds # print the largest window's screen rect "x y w h" // axdriver shot # raise + screenshot the window (occlusion-independent) // axdriver press # find a control by AXIdentifier and press it // axdriver presstitle # find a control by AXTitle and press it +// axdriver pressrole <pid> <role> <idx> # press the Nth element of a role (e.g. AXPopUpButton) // axdriver type <pid> <text> # type Unicode text into the focused element // axdriver key <pid> <virtualKeyCode> # send a key (36=return, 53=esc, 48=tab) import AppKit @@ -44,6 +46,10 @@ func dumpTree(_ e: AXUIElement, _ depth: Int, _ maxDepth: Int) { func find(_ e: AXUIElement, id: String, _ depth: Int = 0) -> AXUIElement? { if ident(e) == id { return e } if depth > 60 { return nil } + // Don't descend into web content — a WKWebView's AXWebArea subtree (e.g. the AI panel) has + // thousands of nodes and makes a full-tree search take many seconds. The native controls we + // look for (activity-bar panels, toolbar buttons, menu items) are never inside a web area. + if role(e) == "AXWebArea" { return nil } for c in children(e) { if let hit = find(c, id: id, depth + 1) { return hit } } return nil } @@ -54,6 +60,13 @@ func findTitle(_ e: AXUIElement, _ wanted: String, _ depth: Int = 0) -> AXUIElem return nil } +func collectByRole(_ e: AXUIElement, _ wanted: String, _ acc: inout [AXUIElement], _ depth: Int = 0) { + if depth > 60 { return } + if role(e) == "AXWebArea" { return } + if role(e) == wanted { acc.append(e) } + for c in children(e) { collectByRole(c, wanted, &acc, depth + 1) } +} + func windowBounds(pid: pid_t) -> CGRect? { let list = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) @@ -67,6 +80,24 @@ func windowBounds(pid: pid_t) -> CGRect? { return nil } +func largestWindowBounds(pid: pid_t) -> CGRect? { + // The biggest on-screen window for the pid — this is the main terminal window even when a + // floating panel (command palette / quick connect) is frontmost, so a region screenshot of + // this rect captures the panel composited over the terminal instead of the bare panel. + let list = + CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]] ?? [] + var best: CGRect? + for w in list where (w[kCGWindowOwnerPID as String] as? pid_t) == pid { + let b = w[kCGWindowBounds as String] as? [String: CGFloat] ?? [:] + let rect = CGRect( + x: b["X"] ?? 0, y: b["Y"] ?? 0, width: b["Width"] ?? 0, height: b["Height"] ?? 0) + if rect.width < 120 || rect.height < 80 { continue } + if best == nil || rect.width * rect.height > best!.width * best!.height { best = rect } + } + return best +} + func windowID(pid: pid_t, nameContains: String? = nil) -> CGWindowID? { // On-screen windows come back front-to-back, so the first match is the frontmost. We don't // restrict to layer 0 — floating panels (command palette, quick connect) sit on a higher layer @@ -118,6 +149,14 @@ case "raise": raise(pid: pid) case "windowid": if let id = windowID(pid: pid) { print(id) } else { exit(1) } +case "bounds": + // Print the largest window's screen rect as "x y w h" (points, top-left origin) for + // `screencapture -R`. Used to composite a floating panel over the terminal in one frame. + if let b = largestWindowBounds(pid: pid) { + print("\(Int(b.minX)) \(Int(b.minY)) \(Int(b.width)) \(Int(b.height))") + } else { + exit(1) + } case "shot": guard args.count > 3 else { exit(64) } raise(pid: pid) @@ -134,9 +173,23 @@ case "shot": exit(p.terminationStatus == 0 ? 0 : 1) case "press", "presstitle": guard args.count > 3 else { exit(64) } - let target = args[1] == "press" ? find(app, id: args[3]) : findTitle(app, args[3]) + let byId = args[1] == "press" + // Search the tiny menu bar first — most actions (Settings, Split, Command Palette, Quick Connect, + // New Tab) are menu items, and walking the full window tree (terminal grid + web panels) is slow. + let menuBar = attr(app, kAXMenuBarAttribute as String).map { $0 as! AXUIElement } + let target = + (menuBar.flatMap { byId ? find($0, id: args[3]) : findTitle($0, args[3]) }) + ?? (byId ? find(app, id: args[3]) : findTitle(app, args[3])) guard let el = target else { err("element not found: \(args[3])"); exit(1) } exit(AXUIElementPerformAction(el, kAXPressAction as CFString) == .success ? 0 : 1) +case "pressrole": + // pressrole <pid> <role> <index> — press the Nth (0-based) element of a role (e.g. an + // AXPopUpButton) to open its menu, so a following `presstitle` can pick an item. + guard args.count > 4, let idx = Int(args[4]) else { exit(64) } + var found: [AXUIElement] = [] + collectByRole(app, args[3], &found) + guard idx < found.count else { err("only \(found.count) \(args[3]) found"); exit(1) } + exit(AXUIElementPerformAction(found[idx], kAXPressAction as CFString) == .success ? 0 : 1) case "click": // click <pid> <xFrac> <yFrac> — left-click at a fractional point inside the window // (0.5 0.5 = center). Guarantees the window is key + terminal is first responder before a