| name | cursor-editor-agent-tabs |
| description | How the Leap Monitor shows read-only rows for open Cursor (the editor) Agent/Composer tabs - the on-disk SQLite scan (scan_open_cursor_agents), status mapping, tab-level focus/jump via the Cursor extension (focus_cursor_window), synthetic row reconciliation, and the two close buttons. Use this when working on cursor_gui_scan.py, Cursor GUI agent rows, or Cursor tab navigation. |
| user-invocable | false |
Cursor Editor Agent Tabs (read-only monitor rows)
Optional, on by default (Settings -> "Show Cursor editor Agent tabs", persisted as show_cursor_gui_agents in monitor_prefs.json; the three default reads - the SessionRefreshWorker gate + the two settings-dialog plumbing reads - all default to True, so a user who never touches the toggle gets the rows; the scan is a cheap no-op when Cursor isn't running). When on, SessionRefreshWorker calls scan_open_cursor_agents() each refresh tick and the monitor shows one row per open Cursor (the editor) Agent/Composer tab. These are NOT Leap sessions - they are a pure display overlay (no PTY, no server, no queueing).
Why it's read-only (but tab-level jump works). Cursor's Agent tabs live entirely inside its Electron app - no PTY/socket/public API to drive them (so no queueing/sending), and Cursor exposes nothing clickable to macOS Accessibility (verified live: 0 AX windows, AXManualAccessibility unsupported, the window collapses to nested empty AXGroups). But focusing a tab is possible via Cursor's own command registry: focus_cursor_window(folder, composer_id) raises the window through the System Events AX bridge (AXRaise by window title - Cursor has no usable AppleScript dictionary, but System Events can read native window titles = workspace folder names), then writes focusComposer:<composer_id> to ~/.leap-terminal-request. The Leap Cursor extension (src/leap/vscode-extension/extension.js, Cursor-gated via isCursor()) picks that up in the now-foreground window and calls vscode.commands.executeCommand('composer.openComposer', composerId) - the bare id string is essential: that reaches Cursor's openComposerImpl fast-path selectedComposerIds.includes(id) -> showAndFocus(id), the actual visible tab switch (passing an object skips it, which is why glass.openAgentById resolved but never switched). Fallbacks composer.openComposerFromNotification {composerId} then glass.openAgentById cover builds without that command. Confirmed working live. Best-effort: if the extension isn't loaded or a Cursor build gates the command, the window-level raise still happened. The same .vsix ships to VS Code too, so the focusComposer: handler and the commands are strictly gated to Cursor (isCursor()), and a non-Cursor window leaves the shared request file untouched rather than consuming it.
Data source (all on-disk SQLite, undocumented/version-fragile -> every read is defensive). scan_open_cursor_agents() joins three stores under ~/Library/Application Support/Cursor/User/: (1) workspaceStorage/<hash>/workspace.json -> {"folder": "file://.../<project>"}; (2) that workspace's state.vscdb -> ItemTable['composer.composerData'].selectedComposerIds (the tabs open in that window); (3) global globalStorage/state.vscdb -> cursorDiskKV['composerData:<id>'] per tab (name, status, generatingBubbleIds, hasUnreadMessages). Which workspaces are open is detected from the state.vscdb file handles Cursor holds open (lsof -p <cursor pids> -> workspaceStorage/<hash>/state.vscdb), TTL-cached. This is frontmost-independent: System Events / AX only enumerate Cursor's windows when it's the active app, so a window-title approach made the rows vanish whenever you looked away from Cursor. Reads use a read-only WAL connection (?mode=ro + PRAGMA query_only) with a copy-to-temp fallback, cached by db mtime signature.
Status mapping (_derive_status, derived from persisted fields - Cursor's richer in-memory activityState isn't on disk): generatingBubbleIds non-empty OR status == 'generating' -> running; else hasUnreadMessages -> unread; else idle. Two deliberate, code-verified choices: (1) no "Aborted" - Cursor writes status='aborted' whenever a generation ends (updateComposer(h, {chatGenerationUUID: undefined, status:'aborted'})), including normal completion, so it maps to idle not a scary "Aborted"; (2) unread, not "replied" - hasUnreadMessages is Cursor's manual "Mark as unread" flag (cleared on view), not an auto "agent replied" signal. Status lags the live GUI by a poll tick (disk-flushed).
Integration design. Rows carry row_type == 'cursor_agent_gui' and a synthetic tag cursor-gui:<composerId>. They are kept in MonitorWindow._cursor_gui_rows, never in self.sessions outside the render, so they bypass the server-centric paths that read the leap-only self.sessions (pinned-session auto-pin, sleep guard, dock-badge notifications). _update_table builds combined = self.sessions + _cursor_gui_rows, interleaves it by row_order (a Cursor tag joins row_order like any leap tag, so Cursor rows can be dragged and reordered exactly like regular rows and remember their slot by composer id across close/reopen - new tabs append at the end), renders against combined, then restores self.sessions to the leap-only list in finally. Drag-drop and the status-fire click handler are tag-based (via the table's _row_tags property, not self.sessions indices) so they work regardless of where a Cursor row lands; the drag payload carries the dragged row's tag (not its index) so a mid-drag table rebuild can't remap it (_reorder_tags_for_drag is the pure, unit-tested reorder). _build_cursor_gui_row() paints all columns itself (no cell-cache) and the Tag cell is alias-aware: right-click -> Set/Rename/Remove alias gives a custom Leap-side label persisted by composer id, independent of Cursor. The composer's own name (from Cursor's built-in right-click -> "Rename Chat", which writes composerData.name) is the default label and flows in automatically.
Two close buttons (mirrors regular-row semantics). The leftmost "×" (_close_cursor_tab_and_untrack) stops PR tracking AND closes the Agent tab, so the row goes away entirely - the analog of a normal row's delete-X. The Server-cell "×" next to "Open" (_close_cursor_tab) closes ONLY the tab and leaves tracking intact - the analog of a normal row's close-server-X that keeps a PR-tracked row alive as a dead row. For this to work, _reconcile_cursor_gui_rows() (called from _on_sessions_refreshed) caches each live row in MonitorWindow._cursor_row_cache and, on the next scan, synthesizes a _tab_closed row (status_text='○ Tab closed') for every tracked Cursor tag whose tab is no longer open - so a tracked tab that's closed via the Server-X stays in the table (and keeps being PR-polled, since the synthesized row carries project_path/branch and is still in _tracked_tags). The synthesized row hides its Server-X (nothing left to close) and its "Open" reopens the chat (best-effort, via the id-based composer command). _untrack_cursor_pr drops the cache entry so an untracked closed tab isn't re-synthesized. The stale-prune keeps any tag that's live OR synthesized (tracked-closed), and only cleans up cursor PR state for tags that are neither.