cmux/Sources/TerminalController.swift
Ismail Pelaseyed d72b014d6d
feat: add markdown viewer panel with live file watching (#883)
* Add markdown viewer panel with live file watching

Introduce a new PanelType.markdown that renders .md files in a dedicated
panel using MarkdownUI (SwiftUI), with live file watching via DispatchSource
so content auto-updates when the file changes on disk.

- New MarkdownPanel class with file system watcher (write/delete/rename/extend)
- New MarkdownPanelView with custom cmux theme (headings, code blocks, tables,
  blockquotes, inline code, lists, horizontal rules, light/dark mode)
- Full workspace integration: SurfaceKind, creation methods, tab subscription
- Session persistence: snapshot/restore across app restarts
- V2 socket command: markdown.open (validates path, resolves workspace, splits)
- CLI command: cmux markdown open <path> with routing flags and help text
- Agent skill: skills/cmux-markdown/ with SKILL.md, openai.yaml, and references
- Cross-link from skills/cmux/SKILL.md to the new markdown skill
- SPM dependency: gonzalezreal/swift-markdown-ui 2.4.1

* Fix unreachable guard in markdown subcommand dispatch

Use looksLikePath() to distinguish subcommands from path arguments
so the guard can catch unknown subcommands and future subcommands
are parsed correctly.

* Use .isoLatin1 fallback instead of .ascii for encoding recovery

ASCII is a strict subset of UTF-8, so falling back to .ascii after
UTF-8 fails is dead code. Use .isoLatin1 which accepts all 256 byte
values and covers legacy encodings like Windows-1252.

* Mark fileWatchSource as nonisolated(unsafe) for deinit safety

deinit is not guaranteed to run on the main actor, so accessing
@MainActor-isolated storage is a data race under strict concurrency.
DispatchSource.cancel() is thread-safe, so nonisolated(unsafe) is
sufficient with a documented invariant that writes only occur on main.

* Fix file watcher reattach: retry loop with cancellation guard

- Replace one-shot 500ms retry with up to 6 attempts (3s total window)
  so files that reappear after a slow atomic replace are picked up
- Add isClosed flag checked before each retry to prevent restarting
  the watcher after close()/deinit

* Harden path validation in markdown.open command

Reject directories and non-absolute paths before panel creation
to prevent ambiguous behavior and generic downstream failures.

* Always reattach file watcher on delete/rename events

After an atomic save (delete old + create new), the DispatchSource still
points to the old inode. Previously we only reattached when the file was
unreadable, so successful atomic saves left the watcher on a stale inode
and live updates silently stopped. Now we always stop and reattach:
immediately if the new file is readable, via retry loop if not.

* Restore markdown panels even when file is missing at launch

MarkdownPanel already handles unavailable files gracefully (shows
'file unavailable' UI and retries via the reattach loop). Dropping
the panel on restore lost the user's layout for files that may
reappear shortly after (network drives, build artifacts, etc.).

* Harden markdown CLI parsing and startup reconnect behavior

---------

Co-authored-by: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com>
2026-03-04 17:48:28 -08:00

13108 lines
547 KiB
Swift

import AppKit
import Carbon.HIToolbox
import Foundation
import Bonsplit
import WebKit
/// Unix socket-based controller for programmatic terminal control
/// Allows automated testing and external control of terminal tabs
@MainActor
class TerminalController {
struct SocketListenerHealth: Sendable {
let isRunning: Bool
let acceptLoopAlive: Bool
let socketPathMatches: Bool
let socketPathExists: Bool
var failureSignals: [String] {
var signals: [String] = []
if !isRunning { signals.append("not_running") }
if !acceptLoopAlive { signals.append("accept_loop_dead") }
if !socketPathMatches { signals.append("socket_path_mismatch") }
if !socketPathExists { signals.append("socket_missing") }
return signals
}
var isHealthy: Bool {
failureSignals.isEmpty
}
}
static let shared = TerminalController()
private nonisolated(unsafe) var socketPath = "/tmp/cmux.sock"
private nonisolated(unsafe) var serverSocket: Int32 = -1
private nonisolated(unsafe) var isRunning = false
private nonisolated(unsafe) var acceptLoopAlive = false
private var clientHandlers: [Int32: Thread] = [:]
private var tabManager: TabManager?
private var accessMode: SocketControlMode = .cmuxOnly
private let myPid = getpid()
private nonisolated(unsafe) static var socketCommandPolicyDepth: Int = 0
private nonisolated(unsafe) static var socketCommandFocusAllowanceStack: [Bool] = []
private nonisolated static let socketCommandPolicyLock = NSLock()
private static let focusIntentV1Commands: Set<String> = [
"focus_window",
"select_workspace",
"focus_surface",
"focus_pane",
"focus_surface_by_panel",
"focus_webview",
"focus_notification",
"activate_app"
]
private static let focusIntentV2Methods: Set<String> = [
"window.focus",
"workspace.select",
"workspace.next",
"workspace.previous",
"workspace.last",
"surface.focus",
"pane.focus",
"pane.last",
"browser.focus_webview",
"browser.focus",
"browser.tab.switch",
"debug.command_palette.toggle",
"debug.notification.focus",
"debug.app.activate"
]
private enum V2HandleKind: String, CaseIterable {
case window
case workspace
case pane
case surface
}
private var v2NextHandleOrdinal: [V2HandleKind: Int] = [
.window: 1,
.workspace: 1,
.pane: 1,
.surface: 1,
]
private var v2RefByUUID: [V2HandleKind: [UUID: String]] = [
.window: [:],
.workspace: [:],
.pane: [:],
.surface: [:],
]
private var v2UUIDByRef: [V2HandleKind: [String: UUID]] = [
.window: [:],
.workspace: [:],
.pane: [:],
.surface: [:],
]
private struct V2BrowserElementRefEntry {
let surfaceId: UUID
let selector: String
}
private struct V2BrowserPendingDialog {
let type: String
let message: String
let defaultText: String?
let responder: (_ accept: Bool, _ text: String?) -> Void
}
private final class V2BrowserUndefinedSentinel {}
private static let v2BrowserEvalEnvelopeTypeKey = "__cmux_t"
private static let v2BrowserEvalEnvelopeValueKey = "__cmux_v"
private static let v2BrowserEvalEnvelopeTypeUndefined = "undefined"
private static let v2BrowserEvalEnvelopeTypeValue = "value"
private var v2BrowserNextElementOrdinal: Int = 1
private var v2BrowserElementRefs: [String: V2BrowserElementRefEntry] = [:]
private var v2BrowserFrameSelectorBySurface: [UUID: String] = [:]
private var v2BrowserInitScriptsBySurface: [UUID: [String]] = [:]
private var v2BrowserInitStylesBySurface: [UUID: [String]] = [:]
private var v2BrowserDialogQueueBySurface: [UUID: [V2BrowserPendingDialog]] = [:]
private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:]
private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:]
private let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel()
private init() {}
nonisolated static func shouldSuppressSocketCommandActivation() -> Bool {
socketCommandPolicyLock.lock()
defer { socketCommandPolicyLock.unlock() }
return socketCommandPolicyDepth > 0
}
nonisolated static func socketCommandAllowsInAppFocusMutations() -> Bool {
allowsInAppFocusMutationsForActiveSocketCommand()
}
private nonisolated static func allowsInAppFocusMutationsForActiveSocketCommand() -> Bool {
socketCommandPolicyLock.lock()
defer { socketCommandPolicyLock.unlock() }
return socketCommandFocusAllowanceStack.last ?? false
}
private static func socketCommandAllowsInAppFocusMutations(commandKey: String, isV2: Bool) -> Bool {
if isV2 {
return focusIntentV2Methods.contains(commandKey)
}
return focusIntentV1Commands.contains(commandKey)
}
private func withSocketCommandPolicy<T>(commandKey: String, isV2: Bool, _ body: () -> T) -> T {
let allowsFocusMutation = Self.socketCommandAllowsInAppFocusMutations(commandKey: commandKey, isV2: isV2)
Self.socketCommandPolicyLock.lock()
Self.socketCommandPolicyDepth += 1
Self.socketCommandFocusAllowanceStack.append(allowsFocusMutation)
Self.socketCommandPolicyLock.unlock()
defer {
Self.socketCommandPolicyLock.lock()
if !Self.socketCommandFocusAllowanceStack.isEmpty {
_ = Self.socketCommandFocusAllowanceStack.popLast()
}
Self.socketCommandPolicyDepth = max(0, Self.socketCommandPolicyDepth - 1)
Self.socketCommandPolicyLock.unlock()
}
return body()
}
private func socketCommandAllowsInAppFocusMutations() -> Bool {
Self.allowsInAppFocusMutationsForActiveSocketCommand()
}
private func v2FocusAllowed(requested: Bool = true) -> Bool {
requested && socketCommandAllowsInAppFocusMutations()
}
private func v2MaybeFocusWindow(for tabManager: TabManager) {
guard socketCommandAllowsInAppFocusMutations(),
let windowId = v2ResolveWindowId(tabManager: tabManager) else { return }
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
setActiveTabManager(tabManager)
}
private func v2MaybeSelectWorkspace(_ tabManager: TabManager, workspace: Workspace) {
guard socketCommandAllowsInAppFocusMutations() else { return }
if tabManager.selectedTabId != workspace.id {
tabManager.selectWorkspace(workspace)
}
}
nonisolated static func shouldReplaceStatusEntry(
current: SidebarStatusEntry?,
key: String,
value: String,
icon: String?,
color: String?,
url: URL?,
priority: Int,
format: SidebarMetadataFormat
) -> Bool {
guard let current else { return true }
return current.key != key ||
current.value != value ||
current.icon != icon ||
current.color != color ||
current.url != url ||
current.priority != priority ||
current.format != format
}
nonisolated static func shouldReplaceMetadataBlock(
current: SidebarMetadataBlock?,
key: String,
markdown: String,
priority: Int
) -> Bool {
guard let current else { return true }
return current.key != key || current.markdown != markdown || current.priority != priority
}
nonisolated static func shouldReplaceProgress(
current: SidebarProgressState?,
value: Double,
label: String?
) -> Bool {
guard let current else { return true }
return current.value != value || current.label != label
}
nonisolated static func shouldReplaceGitBranch(
current: SidebarGitBranchState?,
branch: String,
isDirty: Bool
) -> Bool {
guard let current else { return true }
return current.branch != branch || current.isDirty != isDirty
}
nonisolated static func shouldReplacePullRequest(
current: SidebarPullRequestState?,
number: Int,
label: String,
url: URL,
status: SidebarPullRequestStatus
) -> Bool {
guard let current else { return true }
return current.number != number || current.label != label || current.url != url || current.status != status
}
nonisolated static func shouldReplacePorts(current: [Int]?, next: [Int]) -> Bool {
let currentSorted = Array(Set(current ?? [])).sorted()
let nextSorted = Array(Set(next)).sorted()
return currentSorted != nextSorted
}
private struct SocketSurfaceKey: Hashable {
let workspaceId: UUID
let panelId: UUID
}
private final class SocketFastPathState: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.cmux.socket-fast-path")
private var lastReportedDirectories: [SocketSurfaceKey: String] = [:]
private let maxTrackedDirectories = 4096
func shouldPublishDirectory(workspaceId: UUID, panelId: UUID, directory: String) -> Bool {
let key = SocketSurfaceKey(workspaceId: workspaceId, panelId: panelId)
return queue.sync {
if lastReportedDirectories[key] == directory {
return false
}
if lastReportedDirectories.count >= maxTrackedDirectories {
lastReportedDirectories.removeAll(keepingCapacity: true)
}
lastReportedDirectories[key] = directory
return true
}
}
}
private static let socketFastPathState = SocketFastPathState()
nonisolated static func explicitSocketScope(
options: [String: String]
) -> (workspaceId: UUID, panelId: UUID)? {
guard let tabRaw = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!tabRaw.isEmpty,
let panelRaw = (options["panel"] ?? options["surface"])?.trimmingCharacters(in: .whitespacesAndNewlines),
!panelRaw.isEmpty,
let workspaceId = UUID(uuidString: tabRaw),
let panelId = UUID(uuidString: panelRaw) else {
return nil
}
return (workspaceId, panelId)
}
nonisolated static func normalizeReportedDirectory(_ directory: String) -> String {
let trimmed = directory.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return directory }
if trimmed.hasPrefix("file://"), let url = URL(string: trimmed), !url.path.isEmpty {
return url.path
}
return trimmed
}
/// Update which window's TabManager receives socket commands.
/// This is used when the user switches between multiple terminal windows.
func setActiveTabManager(_ tabManager: TabManager?) {
self.tabManager = tabManager
}
// MARK: - Process Ancestry Check
/// Get the peer PID of a connected Unix domain socket using LOCAL_PEERPID.
private nonisolated func getPeerPid(_ socket: Int32) -> pid_t? {
var pid: pid_t = 0
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
let result = getsockopt(socket, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize)
if result != 0 || pid <= 0 {
return nil
}
return pid
}
/// Check if the peer has the same UID as this process using LOCAL_PEERCRED.
/// This works even after the peer has disconnected (unlike LOCAL_PEERPID).
private func peerHasSameUID(_ socket: Int32) -> Bool {
var cred = xucred()
var credLen = socklen_t(MemoryLayout<xucred>.size)
let result = getsockopt(socket, SOL_LOCAL, LOCAL_PEERCRED, &cred, &credLen)
guard result == 0 else { return false }
return cred.cr_uid == getuid()
}
/// Check if `pid` is a descendant of this process by walking the process tree.
func isDescendant(_ pid: pid_t) -> Bool {
var current = pid
// Walk up to 128 levels to avoid infinite loops from kernel bugs
for _ in 0..<128 {
if current == myPid {
return true
}
if current <= 1 {
return false
}
let parent = parentPid(of: current)
if parent == current || parent < 0 {
return false
}
current = parent
}
return false
}
/// Get the parent PID of a process using sysctl.
private func parentPid(of pid: pid_t) -> pid_t {
var info = kinfo_proc()
var size = MemoryLayout<kinfo_proc>.size
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
guard sysctl(&mib, 4, &info, &size, nil, 0) == 0 else {
return -1
}
return info.kp_eproc.e_ppid
}
private nonisolated func socketListenerEventData(
stage: String,
errnoCode: Int32? = nil,
extra: [String: Any] = [:]
) -> [String: Any] {
var data: [String: Any] = [
"stage": stage,
"path": socketPath,
"isRunning": isRunning ? 1 : 0,
"acceptLoopAlive": acceptLoopAlive ? 1 : 0,
"serverSocket": Int(serverSocket)
]
if let errnoCode {
data["errno"] = Int(errnoCode)
data["errnoDescription"] = String(cString: strerror(errnoCode))
}
for (key, value) in extra {
data[key] = value
}
return data
}
private nonisolated func reportSocketListenerFailure(
message: String,
stage: String,
errnoCode: Int32? = nil,
extra: [String: Any] = [:]
) {
let data = socketListenerEventData(stage: stage, errnoCode: errnoCode, extra: extra)
sentryBreadcrumb(message, category: "socket", data: data)
sentryCaptureError(message, category: "socket", data: data, contextKey: "socket_listener")
}
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
self.tabManager = tabManager
self.accessMode = accessMode
if isRunning {
if self.socketPath == socketPath && acceptLoopAlive {
self.accessMode = accessMode
applySocketPermissions()
return
}
stop()
}
self.socketPath = socketPath
// Remove existing socket file
unlink(socketPath)
// Create socket
serverSocket = socket(AF_UNIX, SOCK_STREAM, 0)
guard serverSocket >= 0 else {
let errnoCode = errno
print("TerminalController: Failed to create socket")
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: "create_socket",
errnoCode: errnoCode
)
return
}
// Bind to path
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strcpy(pathBuf, ptr)
}
}
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
bind(serverSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard bindResult >= 0 else {
let errnoCode = errno
print("TerminalController: Failed to bind socket")
close(serverSocket)
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: "bind",
errnoCode: errnoCode
)
return
}
applySocketPermissions()
// Listen
guard listen(serverSocket, 5) >= 0 else {
let errnoCode = errno
print("TerminalController: Failed to listen on socket")
close(serverSocket)
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: "listen",
errnoCode: errnoCode
)
return
}
isRunning = true
print("TerminalController: Listening on \(socketPath)")
sentryBreadcrumb(
"socket.listener.listening",
category: "socket",
data: [
"path": socketPath,
"mode": accessMode.rawValue
]
)
// Wire batched port scanner results back to workspace state.
PortScanner.shared.onPortsUpdated = { [weak self] workspaceId, panelId, ports in
MainActor.assumeIsolated {
guard let self, let tabManager = self.tabManager else { return }
guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return }
let validSurfaceIds = Set(workspace.panels.keys)
guard validSurfaceIds.contains(panelId) else { return }
let nextPorts = Array(Set(ports)).sorted()
let currentPorts = workspace.surfaceListeningPorts[panelId] ?? []
guard currentPorts != nextPorts else { return }
if nextPorts.isEmpty {
workspace.surfaceListeningPorts.removeValue(forKey: panelId)
} else {
workspace.surfaceListeningPorts[panelId] = nextPorts
}
workspace.recomputeListeningPorts()
}
}
// Accept connections in background thread
Thread.detachNewThread { [weak self] in
self?.acceptLoop()
}
}
nonisolated func socketListenerHealth(expectedSocketPath: String) -> SocketListenerHealth {
let running = isRunning
let loopAlive = acceptLoopAlive
let pathMatches = socketPath == expectedSocketPath
var st = stat()
let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK
return SocketListenerHealth(
isRunning: running,
acceptLoopAlive: loopAlive,
socketPathMatches: pathMatches,
socketPathExists: exists
)
}
nonisolated func stop() {
isRunning = false
if serverSocket >= 0 {
close(serverSocket)
serverSocket = -1
}
unlink(socketPath)
}
private func applySocketPermissions() {
let permissions = mode_t(accessMode.socketFilePermissions)
if chmod(socketPath, permissions) != 0 {
let errnoCode = errno
print("TerminalController: Failed to set socket permissions to \(String(permissions, radix: 8)) for \(socketPath)")
sentryBreadcrumb(
"socket.listener.permissions.failed",
category: "socket",
data: socketListenerEventData(
stage: "chmod",
errnoCode: errnoCode,
extra: ["permissions": String(permissions, radix: 8)]
)
)
}
}
private func writeSocketResponse(_ response: String, to socket: Int32) {
let payload = response + "\n"
payload.withCString { ptr in
_ = write(socket, ptr, strlen(ptr))
}
}
private func passwordAuthRequiredResponse(for command: String) -> String {
let message = "Authentication required. Send auth <password> first."
guard command.hasPrefix("{"),
let data = command.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {
return "ERROR: Authentication required — send auth <password> first"
}
let id = dict["id"]
return v2Error(id: id, code: "auth_required", message: message)
}
private func passwordLoginV1ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
let lowered = command.lowercased()
guard lowered == "auth" || lowered.hasPrefix("auth ") else {
return nil
}
guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else {
return "ERROR: Password mode is enabled but no socket password is configured in Settings."
}
let provided: String
if lowered == "auth" {
provided = ""
} else {
provided = String(command.dropFirst(5))
}
guard !provided.isEmpty else {
return "ERROR: Missing password. Usage: auth <password>"
}
guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else {
return "ERROR: Invalid password"
}
authenticated = true
return "OK: Authenticated"
}
private func passwordLoginV2ResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
guard command.hasPrefix("{"),
let data = command.data(using: .utf8),
let dict = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {
return nil
}
let id = dict["id"]
let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard method == "auth.login" else {
return nil
}
guard let params = dict["params"] as? [String: Any],
let provided = params["password"] as? String else {
return v2Error(id: id, code: "invalid_params", message: "auth.login requires params.password")
}
guard SocketControlPasswordStore.hasConfiguredPassword(allowLazyKeychainFallback: true) else {
return v2Error(
id: id,
code: "auth_unconfigured",
message: "Password mode is enabled but no socket password is configured in Settings."
)
}
guard SocketControlPasswordStore.verify(password: provided, allowLazyKeychainFallback: true) else {
return v2Error(id: id, code: "auth_failed", message: "Invalid password")
}
authenticated = true
return v2Ok(id: id, result: ["authenticated": true])
}
private func authResponseIfNeeded(for command: String, authenticated: inout Bool) -> String? {
guard accessMode.requiresPasswordAuth else {
return nil
}
if let v2Response = passwordLoginV2ResponseIfNeeded(for: command, authenticated: &authenticated) {
return v2Response
}
if let v1Response = passwordLoginV1ResponseIfNeeded(for: command, authenticated: &authenticated) {
return v1Response
}
if !authenticated {
return passwordAuthRequiredResponse(for: command)
}
return nil
}
private nonisolated func acceptLoop() {
acceptLoopAlive = true
sentryBreadcrumb(
"socket.listener.accept_loop.started",
category: "socket",
data: socketListenerEventData(stage: "accept_loop_start")
)
var exitReason = "stopped"
var lastAcceptErrno: Int32?
defer {
if isRunning && exitReason == "stopped" {
exitReason = "unexpected_loop_exit"
}
let shouldCaptureExit = exitReason != "stopped"
acceptLoopAlive = false
isRunning = false
if shouldCaptureExit {
let data = socketListenerEventData(
stage: "accept_loop_exit",
errnoCode: lastAcceptErrno,
extra: ["reason": exitReason]
)
sentryBreadcrumb("socket.listener.accept_loop.exited", category: "socket", data: data)
sentryCaptureError(
"socket.listener.accept_loop.exited",
category: "socket",
data: data,
contextKey: "socket_listener"
)
}
}
var consecutiveFailures = 0
while isRunning {
var clientAddr = sockaddr_un()
var clientAddrLen = socklen_t(MemoryLayout<sockaddr_un>.size)
let clientSocket = withUnsafeMutablePointer(to: &clientAddr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
accept(serverSocket, sockaddrPtr, &clientAddrLen)
}
}
guard clientSocket >= 0 else {
if isRunning {
let errnoCode = errno
lastAcceptErrno = errnoCode
consecutiveFailures += 1
print("TerminalController: Accept failed (\(consecutiveFailures) consecutive)")
if consecutiveFailures == 1 || consecutiveFailures % 10 == 0 {
sentryBreadcrumb(
"socket.listener.accept.failed",
category: "socket",
data: socketListenerEventData(
stage: "accept",
errnoCode: errnoCode,
extra: ["consecutiveFailures": consecutiveFailures]
)
)
}
if consecutiveFailures >= 50 {
print("TerminalController: Too many consecutive accept failures, exiting accept loop")
exitReason = "too_many_accept_failures"
break
}
usleep(10_000) // 10ms backoff
}
continue
}
consecutiveFailures = 0
// Capture peer PID immediately before the client can disconnect.
// ncat --send-only closes the connection right after writing, so by
// the time a new thread starts the peer may already be gone.
let peerPid = getPeerPid(clientSocket)
// Handle client in new thread
Thread.detachNewThread { [weak self] in
self?.handleClient(clientSocket, peerPid: peerPid)
}
}
}
private func handleClient(_ socket: Int32, peerPid: pid_t? = nil) {
defer { close(socket) }
// In cmuxOnly mode, verify the connecting process is a descendant of cmux.
// Other modes allow external clients and apply separate auth controls.
if accessMode == .cmuxOnly {
// Use pre-captured peer PID if available (captured in accept loop before
// the peer can disconnect), falling back to live lookup.
let pid = peerPid ?? getPeerPid(socket)
if let pid {
guard isDescendant(pid) else {
let msg = "ERROR: Access denied — only processes started inside cmux can connect\n"
msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) }
return
}
}
// If pid is nil, LOCAL_PEERPID failed (peer disconnected before we
// could read it common with ncat --send-only). We still verify the
// peer runs as the same user via LOCAL_PEERCRED. This is the same
// security boundary as the socket file permissions (0600), so it does
// not widen the attack surface. We also require that the peer actually
// sent data (checked in the read loop below) a connect-only probe
// with no data is harmless.
if pid == nil {
guard peerHasSameUID(socket) else {
let msg = "ERROR: Unable to verify client process\n"
msg.withCString { ptr in _ = write(socket, ptr, strlen(ptr)) }
return
}
}
}
var buffer = [UInt8](repeating: 0, count: 4096)
var pending = ""
var authenticated = false
while isRunning {
let bytesRead = read(socket, &buffer, buffer.count - 1)
guard bytesRead > 0 else { break }
let chunk = String(bytes: buffer[0..<bytesRead], encoding: .utf8) ?? ""
pending.append(chunk)
while let newlineIndex = pending.firstIndex(of: "\n") {
let line = String(pending[..<newlineIndex])
pending = String(pending[pending.index(after: newlineIndex)...])
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { continue }
if let authResponse = authResponseIfNeeded(for: trimmed, authenticated: &authenticated) {
writeSocketResponse(authResponse, to: socket)
continue
}
let response = processCommand(trimmed)
writeSocketResponse(response, to: socket)
}
}
}
private func processCommand(_ command: String) -> String {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Empty command" }
// v2 protocol: newline-delimited JSON.
if trimmed.hasPrefix("{") {
return processV2Command(trimmed)
}
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
guard !parts.isEmpty else { return "ERROR: Empty command" }
let cmd = parts[0].lowercased()
let args = parts.count > 1 ? parts[1] : ""
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
let response = withSocketCommandPolicy(commandKey: cmd, isV2: false) {
switch cmd {
case "ping":
return "PONG"
case "auth":
return "OK: Authentication not required"
case "list_windows":
return listWindows()
case "current_window":
return currentWindow()
case "focus_window":
return focusWindow(args)
case "new_window":
return newWindow()
case "close_window":
return closeWindow(args)
case "move_workspace_to_window":
return moveWorkspaceToWindow(args)
case "list_workspaces":
return listWorkspaces()
case "new_workspace":
return newWorkspace()
case "new_split":
return newSplit(args)
case "list_surfaces":
return listSurfaces(args)
case "focus_surface":
return focusSurface(args)
case "close_workspace":
return closeWorkspace(args)
case "select_workspace":
return selectWorkspace(args)
case "current_workspace":
return currentWorkspace()
case "send":
return sendInput(args)
case "send_key":
return sendKey(args)
case "send_surface":
return sendInputToSurface(args)
case "send_key_surface":
return sendKeyToSurface(args)
case "notify":
return notifyCurrent(args)
case "notify_surface":
return notifySurface(args)
case "notify_target":
return notifyTarget(args)
case "list_notifications":
return listNotifications()
case "clear_notifications":
return clearNotifications(args)
case "set_app_focus":
return setAppFocusOverride(args)
case "simulate_app_active":
return simulateAppDidBecomeActive()
case "set_status":
return setStatus(args)
case "report_meta":
return reportMeta(args)
case "report_meta_block":
return reportMetaBlock(args)
case "clear_status":
return clearStatus(args)
case "clear_meta":
return clearMeta(args)
case "clear_meta_block":
return clearMetaBlock(args)
case "list_status":
return listStatus(args)
case "list_meta":
return listMeta(args)
case "list_meta_blocks":
return listMetaBlocks(args)
case "log":
return appendLog(args)
case "clear_log":
return clearLog(args)
case "list_log":
return listLog(args)
case "set_progress":
return setProgress(args)
case "clear_progress":
return clearProgress(args)
case "report_git_branch":
return reportGitBranch(args)
case "clear_git_branch":
return clearGitBranch(args)
case "report_pr":
return reportPullRequest(args)
case "report_review":
return reportPullRequest(args)
case "clear_pr":
return clearPullRequest(args)
case "report_ports":
return reportPorts(args)
case "clear_ports":
return clearPorts(args)
case "report_tty":
return reportTTY(args)
case "ports_kick":
return portsKick(args)
case "report_pwd":
return reportPwd(args)
case "sidebar_state":
return sidebarState(args)
case "reset_sidebar":
return resetSidebar(args)
case "read_screen":
return readScreenText(args)
#if DEBUG
case "set_shortcut":
return setShortcut(args)
case "simulate_shortcut":
return simulateShortcut(args)
case "simulate_type":
return simulateType(args)
case "simulate_file_drop":
return simulateFileDrop(args)
case "seed_drag_pasteboard_fileurl":
return seedDragPasteboardFileURL()
case "seed_drag_pasteboard_tabtransfer":
return seedDragPasteboardTabTransfer()
case "seed_drag_pasteboard_sidebar_reorder":
return seedDragPasteboardSidebarReorder()
case "seed_drag_pasteboard_types":
return seedDragPasteboardTypes(args)
case "clear_drag_pasteboard":
return clearDragPasteboard()
case "drop_hit_test":
return dropHitTest(args)
case "drag_hit_chain":
return dragHitChain(args)
case "overlay_hit_gate":
return overlayHitGate(args)
case "overlay_drop_gate":
return overlayDropGate(args)
case "portal_hit_gate":
return portalHitGate(args)
case "sidebar_overlay_gate":
return sidebarOverlayGate(args)
case "terminal_drop_overlay_probe":
return terminalDropOverlayProbe(args)
case "activate_app":
return activateApp()
case "is_terminal_focused":
return isTerminalFocused(args)
case "read_terminal_text":
return readTerminalText(args)
case "render_stats":
return renderStats(args)
case "layout_debug":
return layoutDebug()
case "bonsplit_underflow_count":
return bonsplitUnderflowCount()
case "reset_bonsplit_underflow_count":
return resetBonsplitUnderflowCount()
case "empty_panel_count":
return emptyPanelCount()
case "reset_empty_panel_count":
return resetEmptyPanelCount()
case "focus_notification":
return focusFromNotification(args)
case "flash_count":
return flashCount(args)
case "reset_flash_counts":
return resetFlashCounts()
case "panel_snapshot":
return panelSnapshot(args)
case "panel_snapshot_reset":
return panelSnapshotReset(args)
case "screenshot":
return captureScreenshot(args)
#endif
case "help":
return helpText()
// Browser panel commands
case "open_browser":
return openBrowser(args)
case "navigate":
return navigateBrowser(args)
case "browser_back":
return browserBack(args)
case "browser_forward":
return browserForward(args)
case "browser_reload":
return browserReload(args)
case "get_url":
return getUrl(args)
case "focus_webview":
return focusWebView(args)
case "is_webview_focused":
return isWebViewFocused(args)
case "list_panes":
return listPanes()
case "list_pane_surfaces":
return listPaneSurfaces(args)
case "focus_pane":
return focusPane(args)
case "focus_surface_by_panel":
return focusSurfaceByPanel(args)
case "drag_surface_to_split":
return dragSurfaceToSplit(args)
case "new_pane":
return newPane(args)
case "new_surface":
return newSurface(args)
case "close_surface":
return closeSurface(args)
case "refresh_surfaces":
return refreshSurfaces()
case "surface_health":
return surfaceHealth(args)
default:
return "ERROR: Unknown command '\(cmd)'. Use 'help' for available commands."
}
}
#if DEBUG
if cmd == "new_workspace" || cmd == "send" || cmd == "send_surface" {
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
let status = response.hasPrefix("OK") ? "ok" : "err"
dlog(
"socket.v1 cmd=\(cmd) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
)
}
#endif
return response
}
// MARK: - V2 JSON Socket Protocol
private func processV2Command(_ jsonLine: String) -> String {
// v1 access-mode gating applies to v2 as well. We can't know which v2 method maps
// to which v1 command without parsing, so parse first and then apply allow-list.
guard let data = jsonLine.data(using: .utf8) else {
return v2Encode(["ok": false, "error": ["code": "invalid_utf8", "message": "Invalid UTF-8"]])
}
let object: Any
do {
object = try JSONSerialization.jsonObject(with: data, options: [])
} catch {
return v2Encode(["ok": false, "error": ["code": "parse_error", "message": "Invalid JSON"]])
}
guard let dict = object as? [String: Any] else {
return v2Encode(["ok": false, "error": ["code": "invalid_request", "message": "Expected JSON object"]])
}
let id: Any? = dict["id"]
let method = (dict["method"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let params = dict["params"] as? [String: Any] ?? [:]
guard !method.isEmpty else {
return v2Error(id: id, code: "invalid_request", message: "Missing method")
}
v2MainSync { self.v2RefreshKnownRefs() }
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
let response = withSocketCommandPolicy(commandKey: method, isV2: true) {
switch method {
case "system.ping":
return v2Ok(id: id, result: ["pong": true])
case "system.capabilities":
return v2Ok(id: id, result: v2Capabilities())
case "system.identify":
return v2Ok(id: id, result: v2Identify(params: params))
case "system.tree":
return v2Result(id: id, self.v2SystemTree(params: params))
case "auth.login":
return v2Ok(
id: id,
result: [
"authenticated": true,
"required": accessMode.requiresPasswordAuth
]
)
// Windows
case "window.list":
return v2Result(id: id, self.v2WindowList(params: params))
case "window.current":
return v2Result(id: id, self.v2WindowCurrent(params: params))
case "window.focus":
return v2Result(id: id, self.v2WindowFocus(params: params))
case "window.create":
return v2Result(id: id, self.v2WindowCreate(params: params))
case "window.close":
return v2Result(id: id, self.v2WindowClose(params: params))
// Workspaces
case "workspace.list":
return v2Result(id: id, self.v2WorkspaceList(params: params))
case "workspace.create":
return v2Result(id: id, self.v2WorkspaceCreate(params: params))
case "workspace.select":
return v2Result(id: id, self.v2WorkspaceSelect(params: params))
case "workspace.current":
return v2Result(id: id, self.v2WorkspaceCurrent(params: params))
case "workspace.close":
return v2Result(id: id, self.v2WorkspaceClose(params: params))
case "workspace.move_to_window":
return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params))
case "workspace.reorder":
return v2Result(id: id, self.v2WorkspaceReorder(params: params))
case "workspace.rename":
return v2Result(id: id, self.v2WorkspaceRename(params: params))
case "workspace.action":
return v2Result(id: id, self.v2WorkspaceAction(params: params))
case "workspace.next":
return v2Result(id: id, self.v2WorkspaceNext(params: params))
case "workspace.previous":
return v2Result(id: id, self.v2WorkspacePrevious(params: params))
case "workspace.last":
return v2Result(id: id, self.v2WorkspaceLast(params: params))
// Surfaces / input
case "surface.list":
return v2Result(id: id, self.v2SurfaceList(params: params))
case "surface.current":
return v2Result(id: id, self.v2SurfaceCurrent(params: params))
case "surface.focus":
return v2Result(id: id, self.v2SurfaceFocus(params: params))
case "surface.split":
return v2Result(id: id, self.v2SurfaceSplit(params: params))
case "surface.create":
return v2Result(id: id, self.v2SurfaceCreate(params: params))
case "surface.close":
return v2Result(id: id, self.v2SurfaceClose(params: params))
case "surface.move":
return v2Result(id: id, self.v2SurfaceMove(params: params))
case "surface.reorder":
return v2Result(id: id, self.v2SurfaceReorder(params: params))
case "surface.action":
return v2Result(id: id, self.v2TabAction(params: params))
case "tab.action":
return v2Result(id: id, self.v2TabAction(params: params))
case "surface.drag_to_split":
return v2Result(id: id, self.v2SurfaceDragToSplit(params: params))
case "surface.refresh":
return v2Result(id: id, self.v2SurfaceRefresh(params: params))
case "surface.health":
return v2Result(id: id, self.v2SurfaceHealth(params: params))
case "surface.send_text":
return v2Result(id: id, self.v2SurfaceSendText(params: params))
case "surface.send_key":
return v2Result(id: id, self.v2SurfaceSendKey(params: params))
case "surface.clear_history":
return v2Result(id: id, self.v2SurfaceClearHistory(params: params))
case "surface.trigger_flash":
return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params))
// Panes
case "pane.list":
return v2Result(id: id, self.v2PaneList(params: params))
case "pane.focus":
return v2Result(id: id, self.v2PaneFocus(params: params))
case "pane.surfaces":
return v2Result(id: id, self.v2PaneSurfaces(params: params))
case "pane.create":
return v2Result(id: id, self.v2PaneCreate(params: params))
case "pane.resize":
return v2Result(id: id, self.v2PaneResize(params: params))
case "pane.swap":
return v2Result(id: id, self.v2PaneSwap(params: params))
case "pane.break":
return v2Result(id: id, self.v2PaneBreak(params: params))
case "pane.join":
return v2Result(id: id, self.v2PaneJoin(params: params))
case "pane.last":
return v2Result(id: id, self.v2PaneLast(params: params))
// Notifications
case "notification.create":
return v2Result(id: id, self.v2NotificationCreate(params: params))
case "notification.create_for_surface":
return v2Result(id: id, self.v2NotificationCreateForSurface(params: params))
case "notification.create_for_target":
return v2Result(id: id, self.v2NotificationCreateForTarget(params: params))
case "notification.list":
return v2Ok(id: id, result: self.v2NotificationList())
case "notification.clear":
return v2Result(id: id, self.v2NotificationClear())
// App focus
case "app.focus_override.set":
return v2Result(id: id, self.v2AppFocusOverride(params: params))
case "app.simulate_active":
return v2Result(id: id, self.v2AppSimulateActive())
// Browser
case "browser.open_split":
return v2Result(id: id, self.v2BrowserOpenSplit(params: params))
case "browser.navigate":
return v2Result(id: id, self.v2BrowserNavigate(params: params))
case "browser.back":
return v2Result(id: id, self.v2BrowserBack(params: params))
case "browser.forward":
return v2Result(id: id, self.v2BrowserForward(params: params))
case "browser.reload":
return v2Result(id: id, self.v2BrowserReload(params: params))
case "browser.url.get":
return v2Result(id: id, self.v2BrowserGetURL(params: params))
case "browser.focus_webview":
return v2Result(id: id, self.v2BrowserFocusWebView(params: params))
case "browser.is_webview_focused":
return v2Result(id: id, self.v2BrowserIsWebViewFocused(params: params))
case "browser.snapshot":
return v2Result(id: id, self.v2BrowserSnapshot(params: params))
case "browser.eval":
return v2Result(id: id, self.v2BrowserEval(params: params))
case "browser.wait":
return v2Result(id: id, self.v2BrowserWait(params: params))
case "browser.click":
return v2Result(id: id, self.v2BrowserClick(params: params))
case "browser.dblclick":
return v2Result(id: id, self.v2BrowserDblClick(params: params))
case "browser.hover":
return v2Result(id: id, self.v2BrowserHover(params: params))
case "browser.focus":
return v2Result(id: id, self.v2BrowserFocusElement(params: params))
case "browser.type":
return v2Result(id: id, self.v2BrowserType(params: params))
case "browser.fill":
return v2Result(id: id, self.v2BrowserFill(params: params))
case "browser.press":
return v2Result(id: id, self.v2BrowserPress(params: params))
case "browser.keydown":
return v2Result(id: id, self.v2BrowserKeyDown(params: params))
case "browser.keyup":
return v2Result(id: id, self.v2BrowserKeyUp(params: params))
case "browser.check":
return v2Result(id: id, self.v2BrowserCheck(params: params, checked: true))
case "browser.uncheck":
return v2Result(id: id, self.v2BrowserCheck(params: params, checked: false))
case "browser.select":
return v2Result(id: id, self.v2BrowserSelect(params: params))
case "browser.scroll":
return v2Result(id: id, self.v2BrowserScroll(params: params))
case "browser.scroll_into_view":
return v2Result(id: id, self.v2BrowserScrollIntoView(params: params))
case "browser.screenshot":
return v2Result(id: id, self.v2BrowserScreenshot(params: params))
case "browser.get.text":
return v2Result(id: id, self.v2BrowserGetText(params: params))
case "browser.get.html":
return v2Result(id: id, self.v2BrowserGetHTML(params: params))
case "browser.get.value":
return v2Result(id: id, self.v2BrowserGetValue(params: params))
case "browser.get.attr":
return v2Result(id: id, self.v2BrowserGetAttr(params: params))
case "browser.get.title":
return v2Result(id: id, self.v2BrowserGetTitle(params: params))
case "browser.get.count":
return v2Result(id: id, self.v2BrowserGetCount(params: params))
case "browser.get.box":
return v2Result(id: id, self.v2BrowserGetBox(params: params))
case "browser.get.styles":
return v2Result(id: id, self.v2BrowserGetStyles(params: params))
case "browser.is.visible":
return v2Result(id: id, self.v2BrowserIsVisible(params: params))
case "browser.is.enabled":
return v2Result(id: id, self.v2BrowserIsEnabled(params: params))
case "browser.is.checked":
return v2Result(id: id, self.v2BrowserIsChecked(params: params))
case "browser.find.role":
return v2Result(id: id, self.v2BrowserFindRole(params: params))
case "browser.find.text":
return v2Result(id: id, self.v2BrowserFindText(params: params))
case "browser.find.label":
return v2Result(id: id, self.v2BrowserFindLabel(params: params))
case "browser.find.placeholder":
return v2Result(id: id, self.v2BrowserFindPlaceholder(params: params))
case "browser.find.alt":
return v2Result(id: id, self.v2BrowserFindAlt(params: params))
case "browser.find.title":
return v2Result(id: id, self.v2BrowserFindTitle(params: params))
case "browser.find.testid":
return v2Result(id: id, self.v2BrowserFindTestId(params: params))
case "browser.find.first":
return v2Result(id: id, self.v2BrowserFindFirst(params: params))
case "browser.find.last":
return v2Result(id: id, self.v2BrowserFindLast(params: params))
case "browser.find.nth":
return v2Result(id: id, self.v2BrowserFindNth(params: params))
case "browser.frame.select":
return v2Result(id: id, self.v2BrowserFrameSelect(params: params))
case "browser.frame.main":
return v2Result(id: id, self.v2BrowserFrameMain(params: params))
case "browser.dialog.accept":
return v2Result(id: id, self.v2BrowserDialogRespond(params: params, accept: true))
case "browser.dialog.dismiss":
return v2Result(id: id, self.v2BrowserDialogRespond(params: params, accept: false))
case "browser.download.wait":
return v2Result(id: id, self.v2BrowserDownloadWait(params: params))
case "browser.cookies.get":
return v2Result(id: id, self.v2BrowserCookiesGet(params: params))
case "browser.cookies.set":
return v2Result(id: id, self.v2BrowserCookiesSet(params: params))
case "browser.cookies.clear":
return v2Result(id: id, self.v2BrowserCookiesClear(params: params))
case "browser.storage.get":
return v2Result(id: id, self.v2BrowserStorageGet(params: params))
case "browser.storage.set":
return v2Result(id: id, self.v2BrowserStorageSet(params: params))
case "browser.storage.clear":
return v2Result(id: id, self.v2BrowserStorageClear(params: params))
case "browser.tab.new":
return v2Result(id: id, self.v2BrowserTabNew(params: params))
case "browser.tab.list":
return v2Result(id: id, self.v2BrowserTabList(params: params))
case "browser.tab.switch":
return v2Result(id: id, self.v2BrowserTabSwitch(params: params))
case "browser.tab.close":
return v2Result(id: id, self.v2BrowserTabClose(params: params))
case "browser.console.list":
return v2Result(id: id, self.v2BrowserConsoleList(params: params))
case "browser.console.clear":
return v2Result(id: id, self.v2BrowserConsoleClear(params: params))
case "browser.errors.list":
return v2Result(id: id, self.v2BrowserErrorsList(params: params))
case "browser.highlight":
return v2Result(id: id, self.v2BrowserHighlight(params: params))
case "browser.state.save":
return v2Result(id: id, self.v2BrowserStateSave(params: params))
case "browser.state.load":
return v2Result(id: id, self.v2BrowserStateLoad(params: params))
case "browser.addinitscript":
return v2Result(id: id, self.v2BrowserAddInitScript(params: params))
case "browser.addscript":
return v2Result(id: id, self.v2BrowserAddScript(params: params))
case "browser.addstyle":
return v2Result(id: id, self.v2BrowserAddStyle(params: params))
case "browser.viewport.set":
return v2Result(id: id, self.v2BrowserViewportSet(params: params))
case "browser.geolocation.set":
return v2Result(id: id, self.v2BrowserGeolocationSet(params: params))
case "browser.offline.set":
return v2Result(id: id, self.v2BrowserOfflineSet(params: params))
case "browser.trace.start":
return v2Result(id: id, self.v2BrowserTraceStart(params: params))
case "browser.trace.stop":
return v2Result(id: id, self.v2BrowserTraceStop(params: params))
case "browser.network.route":
return v2Result(id: id, self.v2BrowserNetworkRoute(params: params))
case "browser.network.unroute":
return v2Result(id: id, self.v2BrowserNetworkUnroute(params: params))
case "browser.network.requests":
return v2Result(id: id, self.v2BrowserNetworkRequests(params: params))
case "browser.screencast.start":
return v2Result(id: id, self.v2BrowserScreencastStart(params: params))
case "browser.screencast.stop":
return v2Result(id: id, self.v2BrowserScreencastStop(params: params))
case "browser.input_mouse":
return v2Result(id: id, self.v2BrowserInputMouse(params: params))
case "browser.input_keyboard":
return v2Result(id: id, self.v2BrowserInputKeyboard(params: params))
case "browser.input_touch":
return v2Result(id: id, self.v2BrowserInputTouch(params: params))
// Markdown
case "markdown.open":
return v2Result(id: id, self.v2MarkdownOpen(params: params))
case "surface.read_text":
return v2Result(id: id, self.v2SurfaceReadText(params: params))
#if DEBUG
// Debug / test-only
case "debug.shortcut.set":
return v2Result(id: id, self.v2DebugShortcutSet(params: params))
case "debug.shortcut.simulate":
return v2Result(id: id, self.v2DebugShortcutSimulate(params: params))
case "debug.type":
return v2Result(id: id, self.v2DebugType(params: params))
case "debug.app.activate":
return v2Result(id: id, self.v2DebugActivateApp())
case "debug.command_palette.toggle":
return v2Result(id: id, self.v2DebugToggleCommandPalette(params: params))
case "debug.command_palette.rename_tab.open":
return v2Result(id: id, self.v2DebugOpenCommandPaletteRenameTabInput(params: params))
case "debug.command_palette.visible":
return v2Result(id: id, self.v2DebugCommandPaletteVisible(params: params))
case "debug.command_palette.selection":
return v2Result(id: id, self.v2DebugCommandPaletteSelection(params: params))
case "debug.command_palette.results":
return v2Result(id: id, self.v2DebugCommandPaletteResults(params: params))
case "debug.command_palette.rename_input.interact":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputInteraction(params: params))
case "debug.command_palette.rename_input.delete_backward":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputDeleteBackward(params: params))
case "debug.command_palette.rename_input.selection":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelection(params: params))
case "debug.command_palette.rename_input.select_all":
return v2Result(id: id, self.v2DebugCommandPaletteRenameInputSelectAll(params: params))
case "debug.browser.address_bar_focused":
return v2Result(id: id, self.v2DebugBrowserAddressBarFocused(params: params))
case "debug.sidebar.visible":
return v2Result(id: id, self.v2DebugSidebarVisible(params: params))
case "debug.terminal.is_focused":
return v2Result(id: id, self.v2DebugIsTerminalFocused(params: params))
case "debug.terminal.read_text":
return v2Result(id: id, self.v2DebugReadTerminalText(params: params))
case "debug.terminal.render_stats":
return v2Result(id: id, self.v2DebugRenderStats(params: params))
case "debug.layout":
return v2Result(id: id, self.v2DebugLayout())
case "debug.portal.stats":
return v2Result(id: id, self.v2DebugPortalStats())
case "debug.bonsplit_underflow.count":
return v2Result(id: id, self.v2DebugBonsplitUnderflowCount())
case "debug.bonsplit_underflow.reset":
return v2Result(id: id, self.v2DebugResetBonsplitUnderflowCount())
case "debug.empty_panel.count":
return v2Result(id: id, self.v2DebugEmptyPanelCount())
case "debug.empty_panel.reset":
return v2Result(id: id, self.v2DebugResetEmptyPanelCount())
case "debug.notification.focus":
return v2Result(id: id, self.v2DebugFocusNotification(params: params))
case "debug.flash.count":
return v2Result(id: id, self.v2DebugFlashCount(params: params))
case "debug.flash.reset":
return v2Result(id: id, self.v2DebugResetFlashCounts())
case "debug.panel_snapshot":
return v2Result(id: id, self.v2DebugPanelSnapshot(params: params))
case "debug.panel_snapshot.reset":
return v2Result(id: id, self.v2DebugPanelSnapshotReset(params: params))
case "debug.window.screenshot":
return v2Result(id: id, self.v2DebugScreenshot(params: params))
#endif
default:
return v2Error(id: id, code: "method_not_found", message: "Unknown method")
}
}
#if DEBUG
if method == "workspace.create" || method == "surface.send_text" {
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
let status = response.contains("\"ok\":true") ? "ok" : "err"
dlog(
"socket.v2 method=\(method) status=\(status) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
)
}
#endif
return response
}
private func v2Capabilities() -> [String: Any] {
var methods: [String] = [
"system.ping",
"system.capabilities",
"system.identify",
"system.tree",
"auth.login",
"window.list",
"window.current",
"window.focus",
"window.create",
"window.close",
"workspace.list",
"workspace.create",
"workspace.select",
"workspace.current",
"workspace.close",
"workspace.move_to_window",
"workspace.reorder",
"workspace.rename",
"workspace.action",
"workspace.next",
"workspace.previous",
"workspace.last",
"surface.list",
"surface.current",
"surface.focus",
"surface.split",
"surface.create",
"surface.close",
"surface.drag_to_split",
"surface.move",
"surface.reorder",
"surface.action",
"tab.action",
"surface.refresh",
"surface.health",
"surface.send_text",
"surface.send_key",
"surface.read_text",
"surface.clear_history",
"surface.trigger_flash",
"pane.list",
"pane.focus",
"pane.surfaces",
"pane.create",
"pane.resize",
"pane.swap",
"pane.break",
"pane.join",
"pane.last",
"notification.create",
"notification.create_for_surface",
"notification.create_for_target",
"notification.list",
"notification.clear",
"app.focus_override.set",
"app.simulate_active",
"markdown.open",
"browser.open_split",
"browser.navigate",
"browser.back",
"browser.forward",
"browser.reload",
"browser.url.get",
"browser.snapshot",
"browser.eval",
"browser.wait",
"browser.click",
"browser.dblclick",
"browser.hover",
"browser.focus",
"browser.type",
"browser.fill",
"browser.press",
"browser.keydown",
"browser.keyup",
"browser.check",
"browser.uncheck",
"browser.select",
"browser.scroll",
"browser.scroll_into_view",
"browser.screenshot",
"browser.get.text",
"browser.get.html",
"browser.get.value",
"browser.get.attr",
"browser.get.title",
"browser.get.count",
"browser.get.box",
"browser.get.styles",
"browser.is.visible",
"browser.is.enabled",
"browser.is.checked",
"browser.focus_webview",
"browser.is_webview_focused",
"browser.find.role",
"browser.find.text",
"browser.find.label",
"browser.find.placeholder",
"browser.find.alt",
"browser.find.title",
"browser.find.testid",
"browser.find.first",
"browser.find.last",
"browser.find.nth",
"browser.frame.select",
"browser.frame.main",
"browser.dialog.accept",
"browser.dialog.dismiss",
"browser.download.wait",
"browser.cookies.get",
"browser.cookies.set",
"browser.cookies.clear",
"browser.storage.get",
"browser.storage.set",
"browser.storage.clear",
"browser.tab.new",
"browser.tab.list",
"browser.tab.switch",
"browser.tab.close",
"browser.console.list",
"browser.console.clear",
"browser.errors.list",
"browser.highlight",
"browser.state.save",
"browser.state.load",
"browser.addinitscript",
"browser.addscript",
"browser.addstyle",
"browser.viewport.set",
"browser.geolocation.set",
"browser.offline.set",
"browser.trace.start",
"browser.trace.stop",
"browser.network.route",
"browser.network.unroute",
"browser.network.requests",
"browser.screencast.start",
"browser.screencast.stop",
"browser.input_mouse",
"browser.input_keyboard",
"browser.input_touch",
]
#if DEBUG
methods.append(contentsOf: [
"debug.shortcut.set",
"debug.shortcut.simulate",
"debug.type",
"debug.app.activate",
"debug.command_palette.toggle",
"debug.command_palette.rename_tab.open",
"debug.command_palette.visible",
"debug.command_palette.selection",
"debug.command_palette.results",
"debug.command_palette.rename_input.interact",
"debug.command_palette.rename_input.delete_backward",
"debug.command_palette.rename_input.selection",
"debug.command_palette.rename_input.select_all",
"debug.browser.address_bar_focused",
"debug.sidebar.visible",
"debug.terminal.is_focused",
"debug.terminal.read_text",
"debug.terminal.render_stats",
"debug.layout",
"debug.portal.stats",
"debug.bonsplit_underflow.count",
"debug.bonsplit_underflow.reset",
"debug.empty_panel.count",
"debug.empty_panel.reset",
"debug.notification.focus",
"debug.flash.count",
"debug.flash.reset",
"debug.panel_snapshot",
"debug.panel_snapshot.reset",
"debug.window.screenshot",
])
#endif
return [
"protocol": "cmux-socket",
"version": 2,
"socket_path": socketPath,
"access_mode": accessMode.rawValue,
"methods": methods.sorted()
]
}
private func v2Identify(params: [String: Any]) -> [String: Any] {
guard let tabManager = v2ResolveTabManager(params: params) else {
return [
"socket_path": socketPath,
"focused": NSNull(),
"caller": NSNull()
]
}
var focused: [String: Any] = [:]
v2MainSync {
let windowId = v2ResolveWindowId(tabManager: tabManager)
if let wsId = tabManager.selectedTabId,
let ws = tabManager.tabs.first(where: { $0.id == wsId }) {
let paneUUID = ws.bonsplitController.focusedPaneId?.id
let surfaceUUID = ws.focusedPanelId
focused = [
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId),
"pane_id": v2OrNull(paneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"surface_id": v2OrNull(surfaceUUID?.uuidString),
"surface_ref": v2Ref(kind: .surface, uuid: surfaceUUID),
"tab_id": v2OrNull(surfaceUUID?.uuidString),
"tab_ref": v2TabRef(uuid: surfaceUUID),
"surface_type": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType.rawValue }),
"is_browser_surface": v2OrNull(surfaceUUID.flatMap { ws.panels[$0]?.panelType == .browser })
]
} else {
focused = [
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
]
}
}
// Optionally validate a caller-provided location (useful for agents calling from inside a surface).
var resolvedCaller: [String: Any]? = nil
if let callerObj = params["caller"] as? [String: Any],
let wsId = v2UUIDAny(callerObj["workspace_id"]) {
let surfaceId = v2UUIDAny(callerObj["surface_id"]) ?? v2UUIDAny(callerObj["tab_id"])
v2MainSync {
let callerTabManager = AppDelegate.shared?.tabManagerFor(tabId: wsId) ?? tabManager
if let ws = callerTabManager.tabs.first(where: { $0.id == wsId }) {
let callerWindowId = v2ResolveWindowId(tabManager: callerTabManager)
var payload: [String: Any] = [
"window_id": v2OrNull(callerWindowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: callerWindowId),
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
]
if let surfaceId, ws.panels[surfaceId] != nil {
let paneUUID = ws.paneId(forPanelId: surfaceId)?.id
payload["surface_id"] = surfaceId.uuidString
payload["surface_ref"] = v2Ref(kind: .surface, uuid: surfaceId)
payload["tab_id"] = surfaceId.uuidString
payload["tab_ref"] = v2TabRef(uuid: surfaceId)
payload["surface_type"] = v2OrNull(ws.panels[surfaceId]?.panelType.rawValue)
payload["is_browser_surface"] = v2OrNull(ws.panels[surfaceId]?.panelType == .browser)
payload["pane_id"] = v2OrNull(paneUUID?.uuidString)
payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneUUID)
} else {
payload["surface_id"] = NSNull()
payload["surface_ref"] = NSNull()
payload["tab_id"] = NSNull()
payload["tab_ref"] = NSNull()
payload["surface_type"] = NSNull()
payload["is_browser_surface"] = NSNull()
payload["pane_id"] = NSNull()
payload["pane_ref"] = NSNull()
}
resolvedCaller = payload
}
}
}
return [
"socket_path": socketPath,
"focused": focused.isEmpty ? NSNull() : focused,
"caller": v2OrNull(resolvedCaller)
]
}
private func v2SystemTree(params: [String: Any]) -> V2CallResult {
let workspaceFilter = v2UUID(params, "workspace_id")
if params["workspace_id"] != nil && workspaceFilter == nil {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
let includeAllWindows = v2Bool(params, "all_windows") ?? false
var identifyParams: [String: Any] = [:]
if let caller = params["caller"] as? [String: Any], !caller.isEmpty {
identifyParams["caller"] = caller
}
let identifyPayload = v2Identify(params: identifyParams)
let focused = identifyPayload["focused"] as? [String: Any] ?? [:]
let caller = identifyPayload["caller"] as? [String: Any] ?? [:]
let focusedWindowId = v2UUIDAny(focused["window_id"]) ?? v2UUIDAny(focused["window_ref"])
var windowNodes: [[String: Any]] = []
var workspaceFound = (workspaceFilter == nil)
v2MainSync {
guard let app = AppDelegate.shared else { return }
let summaries = app.listMainWindowSummaries()
let defaultWindowId = focusedWindowId ?? summaries.first?.windowId
for (windowIndex, summary) in summaries.enumerated() {
guard let manager = app.tabManagerFor(windowId: summary.windowId) else { continue }
if let workspaceFilter {
guard let workspaceIndex = manager.tabs.firstIndex(where: { $0.id == workspaceFilter }) else {
continue
}
let workspace = manager.tabs[workspaceIndex]
let workspaceNode = v2TreeWorkspaceNode(
workspace: workspace,
index: workspaceIndex,
selected: workspace.id == manager.selectedTabId
)
windowNodes = [
v2TreeWindowNode(
summary: summary,
index: windowIndex,
workspaceNodes: [workspaceNode]
)
]
workspaceFound = true
break
}
if !includeAllWindows && summary.windowId != defaultWindowId {
continue
}
let workspaceNodesForWindow = manager.tabs.enumerated().map { workspaceIndex, workspace in
v2TreeWorkspaceNode(
workspace: workspace,
index: workspaceIndex,
selected: workspace.id == manager.selectedTabId
)
}
windowNodes.append(
v2TreeWindowNode(
summary: summary,
index: windowIndex,
workspaceNodes: workspaceNodesForWindow
)
)
}
}
if let workspaceFilter, !workspaceFound {
return .err(
code: "not_found",
message: "Workspace not found",
data: [
"workspace_id": workspaceFilter.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceFilter)
]
)
}
return .ok([
"active": focused.isEmpty ? (NSNull() as Any) : focused,
"caller": caller.isEmpty ? (NSNull() as Any) : caller,
"windows": windowNodes
])
}
private func v2TreeWindowNode(
summary: AppDelegate.MainWindowSummary,
index: Int,
workspaceNodes: [[String: Any]]
) -> [String: Any] {
return [
"id": summary.windowId.uuidString,
"ref": v2Ref(kind: .window, uuid: summary.windowId),
"index": index,
"key": summary.isKeyWindow,
"visible": summary.isVisible,
"workspace_count": workspaceNodes.count,
"selected_workspace_id": v2OrNull(summary.selectedWorkspaceId?.uuidString),
"selected_workspace_ref": v2Ref(kind: .workspace, uuid: summary.selectedWorkspaceId),
"workspaces": workspaceNodes
]
}
private func v2TreeWorkspaceNode(
workspace: Workspace,
index: Int,
selected: Bool
) -> [String: Any] {
var paneByPanelId: [UUID: UUID] = [:]
var indexInPaneByPanelId: [UUID: Int] = [:]
var selectedInPaneByPanelId: [UUID: Bool] = [:]
let paneIds = workspace.bonsplitController.allPaneIds
for paneId in paneIds {
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId)
for (tabIndex, tab) in tabs.enumerated() {
guard let panelId = workspace.panelIdFromSurfaceId(tab.id) else { continue }
paneByPanelId[panelId] = paneId.id
indexInPaneByPanelId[panelId] = tabIndex
selectedInPaneByPanelId[panelId] = (tab.id == selectedTab?.id)
}
}
var surfacesByPane: [UUID: [[String: Any]]] = [:]
let focusedSurfaceId = workspace.focusedPanelId
for (surfaceIndex, panel) in orderedPanels(in: workspace).enumerated() {
let paneUUID = paneByPanelId[panel.id]
let selectedInPane = selectedInPaneByPanelId[panel.id] ?? false
var item: [String: Any] = [
"id": panel.id.uuidString,
"ref": v2Ref(kind: .surface, uuid: panel.id),
"index": surfaceIndex,
"type": panel.panelType.rawValue,
"title": workspace.panelTitle(panelId: panel.id) ?? panel.displayTitle,
"focused": panel.id == focusedSurfaceId,
"selected": selectedInPane,
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id]),
"pane_id": v2OrNull(paneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id])
]
if panel.panelType == .browser, let browserPanel = panel as? BrowserPanel {
item["url"] = browserPanel.currentURL?.absoluteString ?? ""
} else {
item["url"] = NSNull()
}
if let paneUUID {
surfacesByPane[paneUUID, default: []].append(item)
}
}
for paneUUID in surfacesByPane.keys {
surfacesByPane[paneUUID]?.sort {
let lhs = ($0["index_in_pane"] as? Int) ?? ($0["index"] as? Int) ?? Int.max
let rhs = ($1["index_in_pane"] as? Int) ?? ($1["index"] as? Int) ?? Int.max
return lhs < rhs
}
}
let focusedPaneId = workspace.bonsplitController.focusedPaneId
let panes: [[String: Any]] = paneIds.enumerated().map { paneIndex, paneId in
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
let surfaceUUIDs: [UUID] = tabs.compactMap { workspace.panelIdFromSurfaceId($0.id) }
let selectedTab = workspace.bonsplitController.selectedTab(inPane: paneId)
let selectedSurfaceUUID = selectedTab.flatMap { workspace.panelIdFromSurfaceId($0.id) }
return [
"id": paneId.id.uuidString,
"ref": v2Ref(kind: .pane, uuid: paneId.id),
"index": paneIndex,
"focused": paneId == focusedPaneId,
"surface_ids": surfaceUUIDs.map { $0.uuidString },
"surface_refs": surfaceUUIDs.map { v2Ref(kind: .surface, uuid: $0) },
"selected_surface_id": v2OrNull(selectedSurfaceUUID?.uuidString),
"selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID),
"surface_count": surfaceUUIDs.count,
"surfaces": surfacesByPane[paneId.id] ?? []
]
}
return [
"id": workspace.id.uuidString,
"ref": v2Ref(kind: .workspace, uuid: workspace.id),
"index": index,
"title": workspace.title,
"selected": selected,
"pinned": workspace.isPinned,
"panes": panes
]
}
// MARK: - V2 Helpers (encoding + result plumbing)
// MARK: - V2 Helpers (encoding + result plumbing)
private func v2OrNull(_ value: Any?) -> Any {
// Avoid relying on `?? NSNull()` inference (Swift toolchains can disagree).
if let value { return value }
return NSNull()
}
private func v2MainSync<T>(_ body: () -> T) -> T {
if Thread.isMainThread {
return body()
}
return DispatchQueue.main.sync(execute: body)
}
private func v2Ok(id: Any?, result: Any) -> String {
return v2Encode([
"id": v2OrNull(id),
"ok": true,
"result": result
])
}
private func v2Error(id: Any?, code: String, message: String, data: Any? = nil) -> String {
var err: [String: Any] = ["code": code, "message": message]
if let data {
err["data"] = data
}
return v2Encode([
"id": v2OrNull(id),
"ok": false,
"error": err
])
}
private enum V2CallResult {
case ok(Any)
case err(code: String, message: String, data: Any?)
}
private func v2Result(id: Any?, _ res: V2CallResult) -> String {
switch res {
case .ok(let payload):
return v2Ok(id: id, result: payload)
case .err(let code, let message, let data):
return v2Error(id: id, code: code, message: message, data: data)
}
}
private func v2Encode(_ object: Any) -> String {
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: []),
var s = String(data: data, encoding: .utf8) else {
return "{\"ok\":false,\"error\":{\"code\":\"encode_error\",\"message\":\"Failed to encode JSON\"}}"
}
// Ensure single-line responses for the line-oriented socket protocol.
s = s.replacingOccurrences(of: "\n", with: "\\n")
return s
}
private func v2EnsureHandleRef(kind: V2HandleKind, uuid: UUID) -> String {
if let existing = v2RefByUUID[kind]?[uuid] {
return existing
}
let next = v2NextHandleOrdinal[kind] ?? 1
let ref = "\(kind.rawValue):\(next)"
var byUUID = v2RefByUUID[kind] ?? [:]
var byRef = v2UUIDByRef[kind] ?? [:]
byUUID[uuid] = ref
byRef[ref] = uuid
v2RefByUUID[kind] = byUUID
v2UUIDByRef[kind] = byRef
v2NextHandleOrdinal[kind] = next + 1
return ref
}
private func v2ResolveHandleRef(_ handle: String) -> UUID? {
for kind in V2HandleKind.allCases {
if let id = v2UUIDByRef[kind]?[handle] {
return id
}
}
// Tab refs are aliases for surface refs in tab-facing APIs.
let trimmed = handle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if trimmed.hasPrefix("tab:"),
let ordinal = Int(trimmed.replacingOccurrences(of: "tab:", with: "")),
let id = v2UUIDByRef[.surface]?["surface:\(ordinal)"] {
return id
}
return nil
}
private func v2Ref(kind: V2HandleKind, uuid: UUID?) -> Any {
guard let uuid else { return NSNull() }
return v2EnsureHandleRef(kind: kind, uuid: uuid)
}
private func v2TabRef(uuid: UUID?) -> Any {
guard let uuid else { return NSNull() }
let surfaceRef = v2EnsureHandleRef(kind: .surface, uuid: uuid)
return surfaceRef.replacingOccurrences(of: "surface:", with: "tab:")
}
private func v2RefreshKnownRefs() {
guard let app = AppDelegate.shared else { return }
let windows = app.listMainWindowSummaries()
for item in windows {
_ = v2EnsureHandleRef(kind: .window, uuid: item.windowId)
if let tm = app.tabManagerFor(windowId: item.windowId) {
for ws in tm.tabs {
_ = v2EnsureHandleRef(kind: .workspace, uuid: ws.id)
for paneId in ws.bonsplitController.allPaneIds {
_ = v2EnsureHandleRef(kind: .pane, uuid: paneId.id)
}
for panelId in ws.panels.keys {
_ = v2EnsureHandleRef(kind: .surface, uuid: panelId)
}
}
}
}
}
// MARK: - V2 Param Parsing
private func v2String(_ params: [String: Any], _ key: String) -> String? {
guard let raw = params[key] as? String else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func v2ActionKey(_ params: [String: Any], _ key: String = "action") -> String? {
guard let action = v2String(params, key) else { return nil }
return action.lowercased().replacingOccurrences(of: "-", with: "_")
}
private func v2RawString(_ params: [String: Any], _ key: String) -> String? {
params[key] as? String
}
private func v2UUID(_ params: [String: Any], _ key: String) -> UUID? {
guard let s = v2String(params, key) else { return nil }
if let uuid = UUID(uuidString: s) {
return uuid
}
return v2ResolveHandleRef(s)
}
private func v2UUIDAny(_ raw: Any?) -> UUID? {
guard let s = raw as? String else { return nil }
let trimmed = s.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let uuid = UUID(uuidString: trimmed) {
return uuid
}
return v2ResolveHandleRef(trimmed)
}
private func v2Bool(_ params: [String: Any], _ key: String) -> Bool? {
if let b = params[key] as? Bool { return b }
if let n = params[key] as? NSNumber { return n.boolValue }
if let s = params[key] as? String {
switch s.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return nil
}
}
return nil
}
private func v2LocatePane(_ paneUUID: UUID) -> (windowId: UUID, tabManager: TabManager, workspace: Workspace, paneId: PaneID)? {
guard let app = AppDelegate.shared else { return nil }
let windows = app.listMainWindowSummaries()
for item in windows {
guard let tm = app.tabManagerFor(windowId: item.windowId) else { continue }
for ws in tm.tabs {
if let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) {
return (item.windowId, tm, ws, paneId)
}
}
}
return nil
}
private func v2Int(_ params: [String: Any], _ key: String) -> Int? {
if let i = params[key] as? Int { return i }
if let n = params[key] as? NSNumber { return n.intValue }
if let s = params[key] as? String { return Int(s) }
return nil
}
private func v2PanelType(_ params: [String: Any], _ key: String) -> PanelType? {
guard let s = v2String(params, key) else { return nil }
return PanelType(rawValue: s.lowercased())
}
// MARK: - V2 Context Resolution
private func v2ResolveTabManager(params: [String: Any]) -> TabManager? {
// Prefer explicit window_id routing. Fall back to global lookup by workspace_id/surface_id/tab_id,
// and finally to the active window's TabManager.
if let windowId = v2UUID(params, "window_id") {
return v2MainSync { AppDelegate.shared?.tabManagerFor(windowId: windowId) }
}
if let wsId = v2UUID(params, "workspace_id") {
if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(tabId: wsId) }) {
return tm
}
}
if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") {
if let tm = v2MainSync({ AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager }) {
return tm
}
}
return tabManager
}
private func v2ResolveWindowId(tabManager: TabManager?) -> UUID? {
guard let tabManager else { return nil }
return v2MainSync { AppDelegate.shared?.windowId(for: tabManager) }
}
// MARK: - V2 Window Methods
private func v2WindowList(params _: [String: Any]) -> V2CallResult {
let windows = v2MainSync { AppDelegate.shared?.listMainWindowSummaries() } ?? []
let payload: [[String: Any]] = windows.enumerated().map { index, item in
return [
"id": item.windowId.uuidString,
"ref": v2Ref(kind: .window, uuid: item.windowId),
"index": index,
"key": item.isKeyWindow,
"visible": item.isVisible,
"workspace_count": item.workspaceCount,
"selected_workspace_id": v2OrNull(item.selectedWorkspaceId?.uuidString),
"selected_workspace_ref": v2Ref(kind: .workspace, uuid: item.selectedWorkspaceId)
]
}
return .ok(["windows": payload])
}
private func v2WindowCurrent(params _: [String: Any]) -> V2CallResult {
guard let tabManager else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let windowId = v2ResolveWindowId(tabManager: tabManager) else {
return .err(code: "not_found", message: "Current window not found", data: nil)
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
private func v2WindowFocus(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
let ok = v2MainSync { AppDelegate.shared?.focusMainWindow(windowId: windowId) ?? false }
return ok
? .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
: .err(code: "not_found", message: "Window not found", data: [
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
private func v2WindowCreate(params _: [String: Any]) -> V2CallResult {
guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else {
return .err(code: "internal_error", message: "Failed to create window", data: nil)
}
// Keep active routing stable unless this command is explicitly focus-intent.
if socketCommandAllowsInAppFocusMutations(),
let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) {
setActiveTabManager(tm)
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
private func v2WindowClose(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
let ok = v2MainSync { AppDelegate.shared?.closeMainWindow(windowId: windowId) ?? false }
return ok
? .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
: .err(code: "not_found", message: "Window not found", data: [
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
// MARK: - V2 Workspace Methods
private func v2WorkspaceList(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var workspaces: [[String: Any]] = []
v2MainSync {
workspaces = tabManager.tabs.enumerated().map { index, ws in
return [
"id": ws.id.uuidString,
"ref": v2Ref(kind: .workspace, uuid: ws.id),
"index": index,
"title": ws.title,
"selected": ws.id == tabManager.selectedTabId,
"pinned": ws.isPinned
]
}
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspaces": workspaces
])
}
private func v2WorkspaceCreate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let cwd: String?
if let raw = params["cwd"] {
guard let str = raw as? String else {
return .err(code: "invalid_params", message: "cwd must be a string", data: nil)
}
cwd = str
} else {
cwd = nil
}
var newId: UUID?
let shouldFocus = v2FocusAllowed()
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
v2MainSync {
let ws = tabManager.addWorkspace(workingDirectory: cwd, select: shouldFocus)
newId = ws.id
}
#if DEBUG
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
dlog(
"socket.workspace.create focus=\(shouldFocus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
)
#endif
guard let newId else {
return .err(code: "internal_error", message: "Failed to create workspace", data: nil)
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": newId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: newId)
])
}
private func v2WorkspaceSelect(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let wsId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
var success = false
v2MainSync {
if let ws = tabManager.tabs.first(where: { $0.id == wsId }) {
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
success = true
}
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return success
? .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
])
: .err(code: "not_found", message: "Workspace not found", data: [
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
])
}
private func v2WorkspaceCurrent(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var wsId: UUID?
v2MainSync {
wsId = tabManager.selectedTabId
}
guard let wsId else {
return .err(code: "not_found", message: "No workspace selected", data: nil)
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
])
}
private func v2WorkspaceClose(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let wsId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
var found = false
v2MainSync {
if let ws = tabManager.tabs.first(where: { $0.id == wsId }) {
tabManager.closeWorkspace(ws)
found = true
}
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return found
? .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
])
: .err(code: "not_found", message: "Workspace not found", data: [
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId)
])
}
private func v2WorkspaceMoveToWindow(params: [String: Any]) -> V2CallResult {
guard let wsId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
var result: V2CallResult = .err(code: "internal_error", message: "Failed to move workspace", data: nil)
v2MainSync {
guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId) else {
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString])
return
}
guard let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId) else {
result = .err(code: "not_found", message: "Window not found", data: ["window_id": windowId.uuidString])
return
}
guard let ws = srcTM.detachWorkspace(tabId: wsId) else {
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString])
return
}
dstTM.attachWorkspace(ws, select: focus)
if focus {
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
setActiveTabManager(dstTM)
}
result = .ok([
"workspace_id": wsId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: wsId),
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspaceReorder(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let workspaceId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
let index = v2Int(params, "index")
let beforeId = v2UUID(params, "before_workspace_id")
let afterId = v2UUID(params, "after_workspace_id")
let targetCount = (index != nil ? 1 : 0) + (beforeId != nil ? 1 : 0) + (afterId != nil ? 1 : 0)
if targetCount != 1 {
return .err(
code: "invalid_params",
message: "Specify exactly one target: index, before_workspace_id, or after_workspace_id",
data: nil
)
}
var moved = false
var newIndex: Int?
v2MainSync {
if let index {
moved = tabManager.reorderWorkspace(tabId: workspaceId, toIndex: index)
} else {
moved = tabManager.reorderWorkspace(tabId: workspaceId, before: beforeId, after: afterId)
}
newIndex = tabManager.tabs.firstIndex(where: { $0.id == workspaceId })
}
guard moved else {
return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceId.uuidString])
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"index": v2OrNull(newIndex)
])
}
private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let workspaceId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
guard let titleRaw = v2String(params, "title"),
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
}
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
var renamed = false
v2MainSync {
guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return }
tabManager.setCustomTitle(tabId: workspaceId, title: title)
renamed = true
}
guard renamed else {
return .err(code: "not_found", message: "Workspace not found", data: [
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId)
])
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"title": title
])
}
private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
v2MainSync {
guard tabManager.selectedTabId != nil else { return }
v2MaybeFocusWindow(for: tabManager)
tabManager.selectNextTab()
guard let workspaceId = tabManager.selectedTabId else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
v2MainSync {
guard tabManager.selectedTabId != nil else { return }
v2MaybeFocusWindow(for: tabManager)
tabManager.selectPreviousTab()
guard let workspaceId = tabManager.selectedTabId else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil)
v2MainSync {
guard let before = tabManager.selectedTabId else { return }
v2MaybeFocusWindow(for: tabManager)
tabManager.navigateBack()
guard let after = tabManager.selectedTabId, after != before else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": after.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: after),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let action = v2ActionKey(params) else {
return .err(code: "invalid_params", message: "Missing action", data: nil)
}
let supportedActions = [
"pin", "unpin", "rename", "clear_name",
"move_up", "move_down", "move_top",
"close_others", "close_above", "close_below",
"mark_read", "mark_unread"
]
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [
"action": action,
"supported_actions": supportedActions
])
v2MainSync {
let requestedWorkspaceId = v2UUID(params, "workspace_id") ?? tabManager.selectedTabId
guard let workspaceId = requestedWorkspaceId,
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
@MainActor
func closeWorkspaces(_ workspaces: [Workspace]) -> Int {
var closed = 0
for candidate in workspaces where candidate.id != workspace.id {
let existedBefore = tabManager.tabs.contains(where: { $0.id == candidate.id })
guard existedBefore else { continue }
tabManager.closeWorkspace(candidate)
if !tabManager.tabs.contains(where: { $0.id == candidate.id }) {
closed += 1
}
}
return closed
}
@MainActor
func finish(_ extras: [String: Any] = [:]) {
var payload: [String: Any] = [
"action": action,
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
]
for (key, value) in extras {
payload[key] = value
}
result = .ok(payload)
}
switch action {
case "pin":
tabManager.setPinned(workspace, pinned: true)
finish(["pinned": true])
case "unpin":
tabManager.setPinned(workspace, pinned: false)
finish(["pinned": false])
case "rename":
guard let titleRaw = v2String(params, "title"),
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
return
}
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
tabManager.setCustomTitle(tabId: workspace.id, title: title)
finish(["title": title])
case "clear_name":
tabManager.clearCustomTitle(tabId: workspace.id)
finish(["title": workspace.title])
case "move_up":
guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: max(currentIndex - 1, 0))
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
case "move_down":
guard let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
_ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1))
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
case "move_top":
tabManager.moveTabToTop(workspace.id)
finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))])
case "close_others":
let candidates = tabManager.tabs.filter { $0.id != workspace.id && !$0.isPinned }
let closed = closeWorkspaces(candidates)
finish(["closed": closed])
case "close_above":
guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let candidates = Array(tabManager.tabs.prefix(index)).filter { !$0.isPinned }
let closed = closeWorkspaces(candidates)
finish(["closed": closed])
case "close_below":
guard let index = tabManager.tabs.firstIndex(where: { $0.id == workspace.id }) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let candidates: [Workspace]
if index + 1 < tabManager.tabs.count {
candidates = Array(tabManager.tabs.suffix(from: index + 1)).filter { !$0.isPinned }
} else {
candidates = []
}
let closed = closeWorkspaces(candidates)
finish(["closed": closed])
case "mark_read":
AppDelegate.shared?.notificationStore?.markRead(forTabId: workspace.id)
finish()
case "mark_unread":
AppDelegate.shared?.notificationStore?.markUnread(forTabId: workspace.id)
finish()
default:
result = .err(code: "invalid_params", message: "Unknown workspace action", data: [
"action": action,
"supported_actions": supportedActions
])
}
}
return result
}
private func v2TabAction(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let action = v2ActionKey(params) else {
return .err(code: "invalid_params", message: "Missing action", data: nil)
}
let supportedActions = [
"rename", "clear_name",
"close_left", "close_right", "close_others",
"new_terminal_right", "new_browser_right",
"reload", "duplicate",
"pin", "unpin", "mark_read", "mark_unread"
]
var result: V2CallResult = .err(code: "invalid_params", message: "Unknown tab action", data: [
"action": action,
"supported_actions": supportedActions
])
v2MainSync {
guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let allowFocusMutation = v2FocusAllowed()
let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") ?? workspace.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused tab", data: nil)
return
}
guard workspace.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Tab not found", data: [
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"tab_id": surfaceId.uuidString,
"tab_ref": v2TabRef(uuid: surfaceId)
])
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
@MainActor
func finish(_ extras: [String: Any] = [:]) {
var payload: [String: Any] = [
"action": action,
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"tab_id": surfaceId.uuidString,
"tab_ref": v2TabRef(uuid: surfaceId)
]
if let paneId = workspace.paneId(forPanelId: surfaceId)?.id {
payload["pane_id"] = paneId.uuidString
payload["pane_ref"] = v2Ref(kind: .pane, uuid: paneId)
} else {
payload["pane_id"] = NSNull()
payload["pane_ref"] = NSNull()
}
for (key, value) in extras {
payload[key] = value
}
result = .ok(payload)
}
@MainActor
func insertionIndexToRight(anchorTabId: TabID, inPane paneId: PaneID) -> Int {
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
guard let anchorIndex = tabs.firstIndex(where: { $0.id == anchorTabId }) else { return tabs.count }
let pinnedCount = tabs.reduce(into: 0) { count, tab in
if let panelId = workspace.panelIdFromSurfaceId(tab.id),
workspace.isPanelPinned(panelId) {
count += 1
}
}
let rawTarget = min(anchorIndex + 1, tabs.count)
return max(rawTarget, pinnedCount)
}
@MainActor
func closeTabs(_ tabIds: [TabID]) -> (closed: Int, skippedPinned: Int) {
var closed = 0
var skippedPinned = 0
for tabId in tabIds {
guard let panelId = workspace.panelIdFromSurfaceId(tabId) else { continue }
if workspace.isPanelPinned(panelId) {
skippedPinned += 1
continue
}
if workspace.panels.count <= 1 {
break
}
if workspace.closePanel(panelId, force: true) {
closed += 1
}
}
return (closed, skippedPinned)
}
switch action {
case "rename":
guard let titleRaw = v2String(params, "title"),
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
result = .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
return
}
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
workspace.setPanelCustomTitle(panelId: surfaceId, title: title)
finish(["title": title])
case "clear_name":
workspace.setPanelCustomTitle(panelId: surfaceId, title: nil)
finish()
case "pin":
workspace.setPanelPinned(panelId: surfaceId, pinned: true)
finish(["pinned": true])
case "unpin":
workspace.setPanelPinned(panelId: surfaceId, pinned: false)
finish(["pinned": false])
case "mark_read", "mark_as_read":
workspace.markPanelRead(surfaceId)
finish()
case "mark_unread", "mark_as_unread":
workspace.markPanelUnread(surfaceId)
finish()
case "reload", "reload_tab":
guard let browserPanel = workspace.browserPanel(for: surfaceId) else {
result = .err(code: "invalid_state", message: "Reload is only available for browser tabs", data: nil)
return
}
browserPanel.reload()
finish()
case "duplicate", "duplicate_tab":
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
let paneId = workspace.paneId(forPanelId: surfaceId),
let browserPanel = workspace.browserPanel(for: surfaceId) else {
result = .err(code: "invalid_state", message: "Duplicate is only available for browser tabs", data: nil)
return
}
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
guard let newPanel = workspace.newBrowserSurface(
inPane: paneId,
url: browserPanel.currentURL,
focus: allowFocusMutation
) else {
result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil)
return
}
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
finish([
"created_surface_id": newPanel.id.uuidString,
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
"created_tab_id": newPanel.id.uuidString,
"created_tab_ref": v2TabRef(uuid: newPanel.id)
])
case "new_terminal_right", "new_terminal_to_right", "new_terminal_tab_to_right":
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
let paneId = workspace.paneId(forPanelId: surfaceId) else {
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
return
}
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: allowFocusMutation) else {
result = .err(code: "internal_error", message: "Failed to create tab", data: nil)
return
}
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
finish([
"created_surface_id": newPanel.id.uuidString,
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
"created_tab_id": newPanel.id.uuidString,
"created_tab_ref": v2TabRef(uuid: newPanel.id)
])
case "new_browser_right", "new_browser_to_right", "new_browser_tab_to_right":
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
let paneId = workspace.paneId(forPanelId: surfaceId) else {
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
return
}
let urlRaw = v2String(params, "url")
let url = urlRaw.flatMap { URL(string: $0) }
if urlRaw != nil && url == nil {
result = .err(code: "invalid_params", message: "Invalid URL", data: ["url": v2OrNull(urlRaw)])
return
}
let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId)
guard let newPanel = workspace.newBrowserSurface(inPane: paneId, url: url, focus: allowFocusMutation) else {
result = .err(code: "internal_error", message: "Failed to create tab", data: nil)
return
}
_ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
finish([
"created_surface_id": newPanel.id.uuidString,
"created_surface_ref": v2Ref(kind: .surface, uuid: newPanel.id),
"created_tab_id": newPanel.id.uuidString,
"created_tab_ref": v2TabRef(uuid: newPanel.id)
])
case "close_left", "close_to_left":
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
let paneId = workspace.paneId(forPanelId: surfaceId) else {
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
return
}
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else {
result = .err(code: "not_found", message: "Tab not found in pane", data: nil)
return
}
let targetIds = Array(tabs.prefix(index).map(\.id))
let closeResult = closeTabs(targetIds)
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
case "close_right", "close_to_right":
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
let paneId = workspace.paneId(forPanelId: surfaceId) else {
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
return
}
let tabs = workspace.bonsplitController.tabs(inPane: paneId)
guard let index = tabs.firstIndex(where: { $0.id == anchorTabId }) else {
result = .err(code: "not_found", message: "Tab not found in pane", data: nil)
return
}
let targetIds = (index + 1 < tabs.count) ? Array(tabs.suffix(from: index + 1).map(\.id)) : []
let closeResult = closeTabs(targetIds)
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
case "close_others", "close_other_tabs":
guard let anchorTabId = workspace.surfaceIdFromPanelId(surfaceId),
let paneId = workspace.paneId(forPanelId: surfaceId) else {
result = .err(code: "not_found", message: "Tab pane not found", data: nil)
return
}
let targetIds = workspace.bonsplitController.tabs(inPane: paneId)
.map(\.id)
.filter { $0 != anchorTabId }
let closeResult = closeTabs(targetIds)
finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned])
default:
result = .err(code: "invalid_params", message: "Unknown tab action", data: [
"action": action,
"supported_actions": supportedActions
])
}
}
return result
}
// MARK: - V2 Surface Methods
private func v2ResolveWorkspace(params: [String: Any], tabManager: TabManager) -> Workspace? {
if let wsId = v2UUID(params, "workspace_id") {
return tabManager.tabs.first(where: { $0.id == wsId })
}
if let surfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") {
return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil })
}
guard let wsId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == wsId })
}
private func v2SurfaceList(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var payload: [String: Any]?
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
// Map panel_id -> pane_id and index/selection within that pane.
var paneByPanelId: [UUID: UUID] = [:]
var indexInPaneByPanelId: [UUID: Int] = [:]
var selectedInPaneByPanelId: [UUID: Bool] = [:]
for paneId in ws.bonsplitController.allPaneIds {
let tabs = ws.bonsplitController.tabs(inPane: paneId)
let selected = ws.bonsplitController.selectedTab(inPane: paneId)
for (idx, tab) in tabs.enumerated() {
guard let panelId = ws.panelIdFromSurfaceId(tab.id) else { continue }
paneByPanelId[panelId] = paneId.id
indexInPaneByPanelId[panelId] = idx
selectedInPaneByPanelId[panelId] = (tab.id == selected?.id)
}
}
let focusedSurfaceId = ws.focusedPanelId
let panels = orderedPanels(in: ws)
let surfaces: [[String: Any]] = panels.enumerated().map { index, panel in
let paneUUID = paneByPanelId[panel.id]
var item: [String: Any] = [
"id": panel.id.uuidString,
"ref": v2Ref(kind: .surface, uuid: panel.id),
"index": index,
"type": panel.panelType.rawValue,
"title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle,
"focused": panel.id == focusedSurfaceId,
"pane_id": v2OrNull(paneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"index_in_pane": v2OrNull(indexInPaneByPanelId[panel.id]),
"selected_in_pane": v2OrNull(selectedInPaneByPanelId[panel.id])
]
return item
}
payload = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surfaces": surfaces
]
}
guard let payload else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
var out = payload
let windowId = v2ResolveWindowId(tabManager: tabManager)
out["window_id"] = v2OrNull(windowId?.uuidString)
out["window_ref"] = v2Ref(kind: .window, uuid: windowId)
return .ok(out)
}
private func v2SurfaceCurrent(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var payload: [String: Any]?
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
// Focus can be transiently nil during startup/reparenting; fall back to first
// ordered panel so callers always get a usable current surface.
let surfaceId = ws.focusedPanelId ?? orderedPanels(in: ws).first?.id
let paneId = surfaceId.flatMap { ws.paneId(forPanelId: $0)?.id }
let windowId = v2ResolveWindowId(tabManager: tabManager)
payload = [
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": v2OrNull(paneId?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneId),
"surface_id": v2OrNull(surfaceId?.uuidString),
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"surface_type": v2OrNull(surfaceId.flatMap { ws.panels[$0]?.panelType.rawValue })
]
}
guard let payload else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
return .ok(payload)
}
private func v2SurfaceFocus(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
guard ws.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
ws.focusPanel(surfaceId)
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
}
return result
}
private func v2SurfaceSplit(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let directionStr = v2String(params, "direction"),
let direction = parseSplitDirection(directionStr) else {
return .err(code: "invalid_params", message: "Missing or invalid direction (left|right|up|down)", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create split", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
let targetSurfaceId: UUID? = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let targetSurfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard ws.panels[targetSurfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": targetSurfaceId.uuidString])
return
}
if let newId = tabManager.newSplit(
tabId: ws.id,
surfaceId: targetSurfaceId,
direction: direction,
focus: v2FocusAllowed()
) {
let paneUUID = ws.paneId(forPanelId: newId)?.id
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": v2OrNull(paneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"surface_id": newId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: newId),
"type": v2OrNull(ws.panels[newId]?.panelType.rawValue)
])
} else {
result = .err(code: "internal_error", message: "Failed to create split", data: nil)
}
}
return result
}
private func v2SurfaceCreate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let panelType = v2PanelType(params, "type") ?? .terminal
let urlStr = v2String(params, "url")
let url = urlStr.flatMap { URL(string: $0) }
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create surface", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
let paneUUID = v2UUID(params, "pane_id")
let paneId: PaneID? = {
if let paneUUID {
return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID })
}
return ws.bonsplitController.focusedPaneId
}()
guard let paneId else {
result = .err(code: "not_found", message: "Pane not found", data: nil)
return
}
let newPanelId: UUID?
if panelType == .browser {
newPanelId = ws.newBrowserSurface(inPane: paneId, url: url, focus: v2FocusAllowed())?.id
} else {
newPanelId = ws.newTerminalSurface(inPane: paneId, focus: v2FocusAllowed())?.id
}
guard let newPanelId else {
result = .err(code: "internal_error", message: "Failed to create surface", data: nil)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": paneId.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: paneId.id),
"surface_id": newPanelId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: newPanelId),
"type": panelType.rawValue
])
}
return result
}
private func v2SurfaceClose(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to close surface", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard ws.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
if ws.panels.count <= 1 {
result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil)
return
}
// Socket API must be non-interactive: bypass close-confirmation gating.
ws.closePanel(surfaceId, force: true)
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
}
return result
}
private func v2SurfaceDragToSplit(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
guard let directionStr = v2String(params, "direction"),
let direction = parseSplitDirection(directionStr) else {
return .err(code: "invalid_params", message: "Missing or invalid direction (left|right|up|down)", data: nil)
}
let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical
let insertFirst = (direction == .left || direction == .up)
var result: V2CallResult = .err(code: "internal_error", message: "Failed to move surface", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
guard let bonsplitTabId = ws.surfaceIdFromPanelId(surfaceId) else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
guard let newPaneId = ws.bonsplitController.splitPane(
orientation: orientation,
movingTab: bonsplitTabId,
insertFirst: insertFirst
) else {
result = .err(code: "internal_error", message: "Failed to split pane", data: nil)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"pane_id": newPaneId.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: newPaneId.id)
])
}
return result
}
private func v2SurfaceMove(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
let requestedPaneUUID = v2UUID(params, "pane_id")
let requestedWorkspaceUUID = v2UUID(params, "workspace_id")
let requestedWindowUUID = v2UUID(params, "window_id")
let beforeSurfaceId = v2UUID(params, "before_surface_id")
let afterSurfaceId = v2UUID(params, "after_surface_id")
let explicitIndex = v2Int(params, "index")
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
let anchorCount = (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0)
if anchorCount > 1 {
return .err(code: "invalid_params", message: "Specify at most one of before_surface_id or after_surface_id", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to move surface", data: nil)
v2MainSync {
guard let app = AppDelegate.shared else {
result = .err(code: "unavailable", message: "AppDelegate not available", data: nil)
return
}
guard let source = app.locateSurface(surfaceId: surfaceId),
let sourceWorkspace = source.tabManager.tabs.first(where: { $0.id == source.workspaceId }) else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
let sourcePane = sourceWorkspace.paneId(forPanelId: surfaceId)
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId)
var targetWindowId = source.windowId
var targetTabManager = source.tabManager
var targetWorkspace = sourceWorkspace
var targetPane = sourcePane ?? sourceWorkspace.bonsplitController.focusedPaneId ?? sourceWorkspace.bonsplitController.allPaneIds.first
var targetIndex = explicitIndex
if let anchorSurfaceId = beforeSurfaceId ?? afterSurfaceId {
guard let anchor = app.locateSurface(surfaceId: anchorSurfaceId),
let anchorWorkspace = anchor.tabManager.tabs.first(where: { $0.id == anchor.workspaceId }),
let anchorPane = anchorWorkspace.paneId(forPanelId: anchorSurfaceId),
let anchorIndex = anchorWorkspace.indexInPane(forPanelId: anchorSurfaceId) else {
result = .err(code: "not_found", message: "Anchor surface not found", data: ["surface_id": anchorSurfaceId.uuidString])
return
}
targetWindowId = anchor.windowId
targetTabManager = anchor.tabManager
targetWorkspace = anchorWorkspace
targetPane = anchorPane
targetIndex = (beforeSurfaceId != nil) ? anchorIndex : (anchorIndex + 1)
} else if let paneUUID = requestedPaneUUID {
guard let located = v2LocatePane(paneUUID) else {
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
return
}
targetWindowId = located.windowId
targetTabManager = located.tabManager
targetWorkspace = located.workspace
targetPane = located.paneId
} else if let workspaceUUID = requestedWorkspaceUUID {
guard let tm = app.tabManagerFor(tabId: workspaceUUID),
let ws = tm.tabs.first(where: { $0.id == workspaceUUID }) else {
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceUUID.uuidString])
return
}
targetTabManager = tm
targetWorkspace = ws
targetWindowId = app.windowId(for: tm) ?? targetWindowId
targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first
} else if let windowUUID = requestedWindowUUID {
guard let tm = app.tabManagerFor(windowId: windowUUID) else {
result = .err(code: "not_found", message: "Window not found", data: ["window_id": windowUUID.uuidString])
return
}
targetWindowId = windowUUID
targetTabManager = tm
guard let selectedWorkspaceId = tm.selectedTabId,
let ws = tm.tabs.first(where: { $0.id == selectedWorkspaceId }) else {
result = .err(code: "not_found", message: "Target window has no selected workspace", data: ["window_id": windowUUID.uuidString])
return
}
targetWorkspace = ws
targetPane = ws.bonsplitController.focusedPaneId ?? ws.bonsplitController.allPaneIds.first
}
guard let destinationPane = targetPane else {
result = .err(code: "not_found", message: "No destination pane", data: nil)
return
}
if targetWorkspace.id == sourceWorkspace.id {
guard sourceWorkspace.moveSurface(panelId: surfaceId, toPane: destinationPane, atIndex: targetIndex, focus: focus) else {
result = .err(code: "internal_error", message: "Failed to move surface", data: nil)
return
}
result = .ok([
"window_id": targetWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: targetWindowId),
"workspace_id": targetWorkspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: targetWorkspace.id),
"pane_id": destinationPane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
])
return
}
guard let transfer = sourceWorkspace.detachSurface(panelId: surfaceId) else {
result = .err(code: "internal_error", message: "Failed to detach surface", data: nil)
return
}
if targetWorkspace.attachDetachedSurface(transfer, inPane: destinationPane, atIndex: targetIndex, focus: focus) == nil {
// Roll back to source workspace if attach fails.
let rollbackPane = sourcePane.flatMap { sp in sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0 == sp }) }
?? sourceWorkspace.bonsplitController.focusedPaneId
?? sourceWorkspace.bonsplitController.allPaneIds.first
if let rollbackPane {
_ = sourceWorkspace.attachDetachedSurface(transfer, inPane: rollbackPane, atIndex: sourceIndex, focus: focus)
}
result = .err(code: "internal_error", message: "Failed to attach surface to destination", data: nil)
return
}
if focus {
v2MaybeFocusWindow(for: targetTabManager)
v2MaybeSelectWorkspace(targetTabManager, workspace: targetWorkspace)
}
result = .ok([
"window_id": targetWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: targetWindowId),
"workspace_id": targetWorkspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: targetWorkspace.id),
"pane_id": destinationPane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
])
}
return result
}
private func v2SurfaceReorder(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
let index = v2Int(params, "index")
let beforeSurfaceId = v2UUID(params, "before_surface_id")
let afterSurfaceId = v2UUID(params, "after_surface_id")
let targetCount = (index != nil ? 1 : 0) + (beforeSurfaceId != nil ? 1 : 0) + (afterSurfaceId != nil ? 1 : 0)
if targetCount != 1 {
return .err(code: "invalid_params", message: "Specify exactly one of index, before_surface_id, or after_surface_id", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to reorder surface", data: nil)
v2MainSync {
guard let app = AppDelegate.shared,
let located = app.locateSurface(surfaceId: surfaceId),
let ws = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }),
let sourcePane = ws.paneId(forPanelId: surfaceId) else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
let targetIndex: Int
if let index {
targetIndex = index
} else if let beforeSurfaceId {
guard let anchorPane = ws.paneId(forPanelId: beforeSurfaceId),
anchorPane == sourcePane,
let anchorIndex = ws.indexInPane(forPanelId: beforeSurfaceId) else {
result = .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil)
return
}
targetIndex = anchorIndex
} else if let afterSurfaceId {
guard let anchorPane = ws.paneId(forPanelId: afterSurfaceId),
anchorPane == sourcePane,
let anchorIndex = ws.indexInPane(forPanelId: afterSurfaceId) else {
result = .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil)
return
}
targetIndex = anchorIndex + 1
} else {
result = .err(code: "invalid_params", message: "Missing reorder target", data: nil)
return
}
guard ws.reorderSurface(panelId: surfaceId, toIndex: targetIndex) else {
result = .err(code: "internal_error", message: "Failed to reorder surface", data: nil)
return
}
result = .ok([
"window_id": located.windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: located.windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": sourcePane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
])
}
return result
}
private func v2SurfaceRefresh(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .ok(["refreshed": 0])
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
var refreshedCount = 0
for panel in ws.panels.values {
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.surface.forceRefresh()
refreshedCount += 1
}
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "refreshed": refreshedCount])
}
return result
}
private func v2SurfaceHealth(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var payload: [String: Any]?
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
let panels = orderedPanels(in: ws)
let items: [[String: Any]] = panels.enumerated().map { index, panel in
var inWindow: Any = NSNull()
if let tp = panel as? TerminalPanel {
inWindow = tp.surface.isViewInWindow
} else if let bp = panel as? BrowserPanel {
inWindow = bp.webView.window != nil
}
return [
"index": index,
"id": panel.id.uuidString,
"ref": v2Ref(kind: .surface, uuid: panel.id),
"type": panel.panelType.rawValue,
"in_window": inWindow
]
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
payload = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surfaces": items,
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
]
}
guard let payload else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
return .ok(payload)
}
private func v2SurfaceSendText(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let text = params["text"] as? String else {
return .err(code: "invalid_params", message: "Missing text", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to send text", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
return
}
#if DEBUG
let sendStart = ProcessInfo.processInfo.systemUptime
#endif
let queued: Bool
if let surface = terminalPanel.surface.surface {
sendSocketText(text, surface: surface)
// Ensure we present a new frame after injecting input so snapshot-based tests (and
// socket-driven agents) can observe the updated terminal without requiring a focus
// change to trigger a draw.
terminalPanel.surface.forceRefresh()
queued = false
} else {
// Avoid blocking the main actor waiting for view/surface attachment.
terminalPanel.sendText(text)
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
queued = true
}
#if DEBUG
let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0
dlog(
"socket.surface.send_text workspace=\(ws.id.uuidString.prefix(8)) surface=\(surfaceId.uuidString.prefix(8)) queued=\(queued ? 1 : 0) chars=\(text.count) ms=\(String(format: "%.2f", sendMs))"
)
#endif
result = .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"queued": queued,
"window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))
])
}
return result
}
private func v2SurfaceSendKey(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let key = v2String(params, "key") else {
return .err(code: "invalid_params", message: "Missing key", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to send key", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
return
}
guard let surface = terminalPanel.surface.surface else {
result = .err(code: "internal_error", message: "Surface not ready", data: ["surface_id": surfaceId.uuidString])
return
}
guard sendNamedKey(surface, keyName: key) else {
result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key])
return
}
terminalPanel.surface.forceRefresh()
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
}
return result
}
private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
return
}
guard terminalPanel.performBindingAction("clear_screen") else {
result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil)
return
}
terminalPanel.surface.forceRefresh()
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var includeScrollback = v2Bool(params, "scrollback") ?? false
let lineLimit = v2Int(params, "lines")
if let lineLimit, lineLimit <= 0 {
return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil)
}
if lineLimit != nil {
includeScrollback = true
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to read terminal text", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
return
}
let response = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
guard response.hasPrefix("OK ") else {
result = .err(code: "internal_error", message: response, data: nil)
return
}
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) }
guard let text = decoded ?? (base64.isEmpty ? "" : nil) else {
result = .err(code: "internal_error", message: "Failed to decode terminal text", data: nil)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"text": text,
"base64": base64,
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" }
let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT
let topLeft = ghostty_point_s(
tag: pointTag,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0
)
let bottomRight = ghostty_point_s(
tag: pointTag,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0
)
let selection = ghostty_selection_s(
top_left: topLeft,
bottom_right: bottomRight,
rectangle: true
)
var text = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &text) else {
return "ERROR: Failed to read terminal text"
}
defer {
ghostty_surface_free_text(surface, &text)
}
let rawData: Data
if let ptr = text.text, text.text_len > 0 {
rawData = Data(bytes: ptr, count: Int(text.text_len))
} else {
rawData = Data()
}
var output = String(decoding: rawData, as: UTF8.self)
if let lineLimit {
output = tailTerminalLines(output, maxLines: lineLimit)
}
let base64 = output.data(using: .utf8)?.base64EncodedString() ?? ""
return "OK \(base64)"
}
private struct PasteboardItemSnapshot {
let representations: [(type: NSPasteboard.PasteboardType, data: Data)]
}
nonisolated static func normalizedExportedScreenPath(_ raw: String?) -> String? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if let url = URL(string: trimmed),
url.isFileURL,
!url.path.isEmpty {
return url.path
}
return trimmed.hasPrefix("/") ? trimmed : nil
}
nonisolated static func shouldRemoveExportedScreenFile(
fileURL: URL,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> Bool {
let standardizedFile = fileURL.standardizedFileURL
let temporary = temporaryDirectory.standardizedFileURL
return standardizedFile.path.hasPrefix(temporary.path + "/")
}
nonisolated static func shouldRemoveExportedScreenDirectory(
fileURL: URL,
temporaryDirectory: URL = FileManager.default.temporaryDirectory
) -> Bool {
let directory = fileURL.deletingLastPathComponent().standardizedFileURL
let temporary = temporaryDirectory.standardizedFileURL
return directory.path.hasPrefix(temporary.path + "/")
}
private func snapshotPasteboardItems(_ pasteboard: NSPasteboard) -> [PasteboardItemSnapshot] {
guard let items = pasteboard.pasteboardItems else { return [] }
return items.map { item in
let representations = item.types.compactMap { type -> (type: NSPasteboard.PasteboardType, data: Data)? in
guard let data = item.data(forType: type) else { return nil }
return (type: type, data: data)
}
return PasteboardItemSnapshot(representations: representations)
}
}
private func restorePasteboardItems(
_ snapshots: [PasteboardItemSnapshot],
to pasteboard: NSPasteboard
) {
_ = pasteboard.clearContents()
guard !snapshots.isEmpty else { return }
let restoredItems = snapshots.compactMap { snapshot -> NSPasteboardItem? in
guard !snapshot.representations.isEmpty else { return nil }
let item = NSPasteboardItem()
for representation in snapshot.representations {
item.setData(representation.data, forType: representation.type)
}
return item
}
guard !restoredItems.isEmpty else { return }
_ = pasteboard.writeObjects(restoredItems)
}
private func readGeneralPasteboardString(_ pasteboard: NSPasteboard) -> String? {
if let urls = pasteboard.readObjects(forClasses: [NSURL.self]) as? [URL],
let firstURL = urls.first,
firstURL.isFileURL {
return firstURL.path
}
if let value = pasteboard.string(forType: .string) {
return value
}
return pasteboard.string(forType: NSPasteboard.PasteboardType("public.utf8-plain-text"))
}
private func readTerminalTextFromVTExportForSnapshot(
terminalPanel: TerminalPanel,
lineLimit: Int?
) -> String? {
// read_text strips style state; VT export keeps ANSI escape sequences.
let pasteboard = NSPasteboard.general
let snapshot = snapshotPasteboardItems(pasteboard)
defer {
restorePasteboardItems(snapshot, to: pasteboard)
}
let initialChangeCount = pasteboard.changeCount
guard terminalPanel.performBindingAction("write_screen_file:copy,vt") else {
return nil
}
guard pasteboard.changeCount != initialChangeCount else {
return nil
}
guard let exportedPath = Self.normalizedExportedScreenPath(readGeneralPasteboardString(pasteboard)) else {
return nil
}
let fileURL = URL(fileURLWithPath: exportedPath)
defer {
if Self.shouldRemoveExportedScreenFile(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL)
if Self.shouldRemoveExportedScreenDirectory(fileURL: fileURL) {
try? FileManager.default.removeItem(at: fileURL.deletingLastPathComponent())
}
}
}
guard let data = try? Data(contentsOf: fileURL),
var output = String(data: data, encoding: .utf8) else {
return nil
}
if let lineLimit {
output = tailTerminalLines(output, maxLines: lineLimit)
}
return output
}
func readTerminalTextForSnapshot(
terminalPanel: TerminalPanel,
includeScrollback: Bool = false,
lineLimit: Int? = nil
) -> String? {
if includeScrollback,
let vtOutput = readTerminalTextFromVTExportForSnapshot(
terminalPanel: terminalPanel,
lineLimit: lineLimit
) {
return vtOutput
}
let response = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
guard response.hasPrefix("OK ") else { return nil }
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
if base64.isEmpty {
return ""
}
guard let data = Data(base64Encoded: base64),
let decoded = String(data: data, encoding: .utf8) else {
return nil
}
return decoded
}
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to trigger flash", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
// Only explicit focus-intent commands may mutate selection state.
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard ws.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
ws.triggerFocusFlash(panelId: surfaceId)
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
}
return result
}
// MARK: - V2 Pane Methods
private func v2PaneList(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var payload: [String: Any]?
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
let focusedPaneId = ws.bonsplitController.focusedPaneId
let panes: [[String: Any]] = ws.bonsplitController.allPaneIds.enumerated().map { index, paneId in
let tabs = ws.bonsplitController.tabs(inPane: paneId)
let surfaceUUIDs: [UUID] = tabs.compactMap { ws.panelIdFromSurfaceId($0.id) }
let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId)
let selectedSurfaceUUID = selectedTab.flatMap { ws.panelIdFromSurfaceId($0.id) }
return [
"id": paneId.id.uuidString,
"ref": v2Ref(kind: .pane, uuid: paneId.id),
"index": index,
"focused": paneId == focusedPaneId,
"surface_ids": surfaceUUIDs.map { $0.uuidString },
"surface_refs": surfaceUUIDs.map { v2Ref(kind: .surface, uuid: $0) },
"selected_surface_id": v2OrNull(selectedSurfaceUUID?.uuidString),
"selected_surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceUUID),
"surface_count": surfaceUUIDs.count
]
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
payload = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"panes": panes,
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
]
}
guard let payload else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
return .ok(payload)
}
private func v2PaneFocus(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let paneUUID = v2UUID(params, "pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
guard let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) else {
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
ws.bonsplitController.focusPane(paneId)
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok(["window_id": v2OrNull(windowId?.uuidString), "window_ref": v2Ref(kind: .window, uuid: windowId), "workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "pane_id": paneId.id.uuidString, "pane_ref": v2Ref(kind: .pane, uuid: paneId.id)])
}
return result
}
private func v2PaneSurfaces(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var payload: [String: Any]?
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
let paneUUID = v2UUID(params, "pane_id")
let paneId: PaneID? = {
if let paneUUID {
return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID })
}
return ws.bonsplitController.focusedPaneId
}()
guard let paneId else { return }
let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId)
let tabs = ws.bonsplitController.tabs(inPane: paneId)
let surfaces: [[String: Any]] = tabs.enumerated().map { index, tab in
let panelId = ws.panelIdFromSurfaceId(tab.id)
let panel = panelId.flatMap { ws.panels[$0] }
return [
"id": v2OrNull(panelId?.uuidString),
"ref": v2Ref(kind: .surface, uuid: panelId),
"index": index,
"title": tab.title,
"type": v2OrNull(panel?.panelType.rawValue),
"selected": tab.id == selectedTab?.id
]
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
payload = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": paneId.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: paneId.id),
"surfaces": surfaces,
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
]
}
guard let payload else {
return .err(code: "not_found", message: "Pane or workspace not found", data: nil)
}
return .ok(payload)
}
private func v2PaneCreate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let directionStr = v2String(params, "direction"),
let direction = parseSplitDirection(directionStr) else {
return .err(code: "invalid_params", message: "Missing or invalid direction (left|right|up|down)", data: nil)
}
let panelType = v2PanelType(params, "type") ?? .terminal
let urlStr = v2String(params, "url")
let url = urlStr.flatMap { URL(string: $0) }
let orientation = direction.orientation
let insertFirst = direction.insertFirst
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create pane", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
guard let focusedPanelId = ws.focusedPanelId else {
result = .err(code: "not_found", message: "No focused surface to split", data: nil)
return
}
let newPanelId: UUID?
if panelType == .browser {
newPanelId = ws.newBrowserSplit(
from: focusedPanelId,
orientation: orientation,
insertFirst: insertFirst,
url: url,
focus: v2FocusAllowed()
)?.id
} else {
newPanelId = ws.newTerminalSplit(
from: focusedPanelId,
orientation: orientation,
insertFirst: insertFirst,
focus: v2FocusAllowed()
)?.id
}
guard let newPanelId else {
result = .err(code: "internal_error", message: "Failed to create pane", data: nil)
return
}
let paneUUID = ws.paneId(forPanelId: newPanelId)?.id
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": v2OrNull(paneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"surface_id": newPanelId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: newPanelId),
"type": panelType.rawValue
])
}
return result
}
private enum V2PaneResizeDirection: String {
case left
case right
case up
case down
var splitOrientation: String {
switch self {
case .left, .right:
return "horizontal"
case .up, .down:
return "vertical"
}
}
/// A split controls the target pane's right/bottom edge when target is first child,
/// and left/top edge when target is second child.
var requiresPaneInFirstChild: Bool {
switch self {
case .right, .down:
return true
case .left, .up:
return false
}
}
/// Positive value moves divider toward second child (right/down).
var dividerDeltaSign: CGFloat {
requiresPaneInFirstChild ? 1 : -1
}
}
private struct V2PaneResizeCandidate {
let splitId: UUID
let orientation: String
let paneInFirstChild: Bool
let dividerPosition: CGFloat
let axisPixels: CGFloat
}
private struct V2PaneResizeTrace {
let containsTarget: Bool
let bounds: CGRect
}
private func v2PaneResizeCollectCandidates(
node: ExternalTreeNode,
targetPaneId: String,
candidates: inout [V2PaneResizeCandidate]
) -> V2PaneResizeTrace {
switch node {
case .pane(let pane):
let bounds = CGRect(
x: pane.frame.x,
y: pane.frame.y,
width: pane.frame.width,
height: pane.frame.height
)
return V2PaneResizeTrace(containsTarget: pane.id == targetPaneId, bounds: bounds)
case .split(let split):
let first = v2PaneResizeCollectCandidates(
node: split.first,
targetPaneId: targetPaneId,
candidates: &candidates
)
let second = v2PaneResizeCollectCandidates(
node: split.second,
targetPaneId: targetPaneId,
candidates: &candidates
)
let combinedBounds = first.bounds.union(second.bounds)
let containsTarget = first.containsTarget || second.containsTarget
if containsTarget,
let splitUUID = UUID(uuidString: split.id) {
let orientation = split.orientation.lowercased()
let axisPixels: CGFloat = orientation == "horizontal"
? combinedBounds.width
: combinedBounds.height
candidates.append(V2PaneResizeCandidate(
splitId: splitUUID,
orientation: orientation,
paneInFirstChild: first.containsTarget,
dividerPosition: CGFloat(split.dividerPosition),
axisPixels: max(axisPixels, 1)
))
}
return V2PaneResizeTrace(containsTarget: containsTarget, bounds: combinedBounds)
}
}
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let directionRaw = (v2String(params, "direction") ?? "").lowercased()
let amount = v2Int(params, "amount") ?? 1
guard let direction = V2PaneResizeDirection(rawValue: directionRaw), amount > 0 else {
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to resize pane", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let paneUUID = v2UUID(params, "pane_id") ?? ws.bonsplitController.focusedPaneId?.id
guard let paneUUID else {
result = .err(code: "not_found", message: "No focused pane", data: nil)
return
}
guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else {
result = .err(code: "not_found", message: "Pane not found", data: ["pane_id": paneUUID.uuidString])
return
}
let tree = ws.bonsplitController.treeSnapshot()
var candidates: [V2PaneResizeCandidate] = []
let trace = v2PaneResizeCollectCandidates(
node: tree,
targetPaneId: paneUUID.uuidString,
candidates: &candidates
)
guard trace.containsTarget else {
result = .err(code: "not_found", message: "Pane not found in split tree", data: ["pane_id": paneUUID.uuidString])
return
}
let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation }
guard !orientationMatches.isEmpty else {
result = .err(
code: "invalid_state",
message: "No \(direction.splitOrientation) split ancestor for pane",
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
)
return
}
guard let candidate = orientationMatches.first(where: { $0.paneInFirstChild == direction.requiresPaneInFirstChild }) else {
result = .err(
code: "invalid_state",
message: "Pane has no adjacent border in direction \(direction.rawValue)",
data: ["pane_id": paneUUID.uuidString, "direction": direction.rawValue]
)
return
}
let delta = CGFloat(amount) / candidate.axisPixels
let requested = candidate.dividerPosition + (direction.dividerDeltaSign * delta)
let clamped = min(max(requested, 0.1), 0.9)
guard ws.bonsplitController.setDividerPosition(clamped, forSplit: candidate.splitId, fromExternal: true) else {
result = .err(
code: "internal_error",
message: "Failed to set split divider position",
data: ["split_id": candidate.splitId.uuidString]
)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": paneUUID.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: paneUUID),
"split_id": candidate.splitId.uuidString,
"direction": direction.rawValue,
"amount": amount,
"old_divider_position": candidate.dividerPosition,
"new_divider_position": clamped
])
}
return result
}
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
guard let sourcePaneUUID = v2UUID(params, "pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil)
}
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
}
if sourcePaneUUID == targetPaneUUID {
return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil)
}
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil)
v2MainSync {
guard let located = v2LocatePane(sourcePaneUUID) else {
result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString])
return
}
guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else {
result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString])
return
}
let workspace = located.workspace
let sourcePane = located.paneId
guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane),
let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane),
let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id),
let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else {
result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil)
return
}
// Keep pane identities stable during swap when one side has a single surface.
var sourcePlaceholder: UUID?
var targetPlaceholder: UUID?
if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 {
sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id
if sourcePlaceholder == nil {
result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil)
return
}
}
if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 {
targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id
if targetPlaceholder == nil {
result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil)
return
}
}
guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else {
result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil)
return
}
guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else {
result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil)
return
}
if let sourcePlaceholder {
_ = workspace.closePanel(sourcePlaceholder, force: true)
}
if let targetPlaceholder {
_ = workspace.closePanel(targetPlaceholder, force: true)
}
if focus {
workspace.bonsplitController.focusPane(targetPane)
}
let windowId = located.windowId
result = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"pane_id": sourcePane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id),
"target_pane_id": targetPane.id.uuidString,
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id),
"source_surface_id": sourceSurfaceId.uuidString,
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
"target_surface_id": targetSurfaceId.uuidString,
"target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId)
])
}
return result
}
private func v2PaneBreak(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? true)
var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil)
v2MainSync {
guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let sourcePaneUUID = v2UUID(params, "pane_id")
let sourcePane: PaneID? = {
if let sourcePaneUUID {
return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID })
}
return sourceWorkspace.bonsplitController.focusedPaneId
}()
let surfaceId: UUID? = {
if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface }
if let sourcePane,
let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) {
return sourceWorkspace.panelIdFromSurfaceId(selected.id)
}
return sourceWorkspace.focusedPanelId
}()
guard let surfaceId else {
result = .err(code: "not_found", message: "No source surface to break", data: nil)
return
}
guard sourceWorkspace.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId)
let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId)
guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else {
result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil)
return
}
let destinationWorkspace = tabManager.addWorkspace(select: focus)
guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId
?? destinationWorkspace.bonsplitController.allPaneIds.first else {
if let sourcePaneForRollback {
_ = sourceWorkspace.attachDetachedSurface(
detached,
inPane: sourcePaneForRollback,
atIndex: sourceIndex,
focus: focus
)
}
result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil)
return
}
guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else {
if let sourcePaneForRollback {
_ = sourceWorkspace.attachDetachedSurface(
detached,
inPane: sourcePaneForRollback,
atIndex: sourceIndex,
focus: focus
)
}
result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": destinationWorkspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id),
"pane_id": destinationPane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
])
}
return result
}
private func v2PaneJoin(params: [String: Any]) -> V2CallResult {
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
}
var surfaceId = v2UUID(params, "surface_id")
if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") {
guard let sourceLocated = v2LocatePane(sourcePaneUUID),
let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId),
let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else {
return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [
"pane_id": sourcePaneUUID.uuidString
])
}
surfaceId = selectedSurface
}
guard let surfaceId else {
return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil)
}
var moveParams: [String: Any] = [
"surface_id": surfaceId.uuidString,
"pane_id": targetPaneUUID.uuidString
]
if let focus = v2Bool(params, "focus") {
moveParams["focus"] = focus
}
return v2SurfaceMove(params: moveParams)
}
private func v2PaneLast(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
guard let focused = ws.bonsplitController.focusedPaneId else {
result = .err(code: "not_found", message: "No focused pane", data: nil)
return
}
guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else {
result = .err(code: "not_found", message: "No alternate pane available", data: nil)
return
}
ws.bonsplitController.focusPane(target)
let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": target.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: target.id),
"surface_id": v2OrNull(selectedSurfaceId?.uuidString),
"surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId)
])
}
return result
}
// MARK: - V2 Notification Methods
private func v2NotificationCreate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let title = (params["title"] as? String) ?? "Notification"
let subtitle = (params["subtitle"] as? String) ?? ""
let body = (params["body"] as? String) ?? ""
var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = ws.focusedPanelId
TerminalNotificationStore.shared.addNotification(
tabId: ws.id,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
body: body
)
result = .ok(["workspace_id": ws.id.uuidString, "surface_id": v2OrNull(surfaceId?.uuidString)])
}
return result
}
private func v2NotificationCreateForSurface(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
let title = (params["title"] as? String) ?? "Notification"
let subtitle = (params["subtitle"] as? String) ?? ""
let body = (params["body"] as? String) ?? ""
var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
guard ws.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
TerminalNotificationStore.shared.addNotification(
tabId: ws.id,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
body: body
)
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
}
return result
}
private func v2NotificationCreateForTarget(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let wsId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
let title = (params["title"] as? String) ?? "Notification"
let subtitle = (params["subtitle"] as? String) ?? ""
let body = (params["body"] as? String) ?? ""
var result: V2CallResult = .err(code: "internal_error", message: "Failed to notify", data: nil)
v2MainSync {
guard let ws = tabManager.tabs.first(where: { $0.id == wsId }) else {
result = .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": wsId.uuidString])
return
}
guard ws.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
TerminalNotificationStore.shared.addNotification(
tabId: ws.id,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
body: body
)
result = .ok(["workspace_id": ws.id.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), "surface_id": surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), "window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString), "window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))])
}
return result
}
private func v2NotificationList() -> [String: Any] {
var items: [[String: Any]] = []
DispatchQueue.main.sync {
items = TerminalNotificationStore.shared.notifications.map { n in
return [
"id": n.id.uuidString,
"workspace_id": n.tabId.uuidString,
"surface_id": v2OrNull(n.surfaceId?.uuidString),
"is_read": n.isRead,
"title": n.title,
"subtitle": n.subtitle,
"body": n.body
]
}
}
return ["notifications": items]
}
private func v2NotificationClear() -> V2CallResult {
DispatchQueue.main.sync {
TerminalNotificationStore.shared.clearAll()
}
return .ok([:])
}
// MARK: - V2 App Focus Methods
private func v2AppFocusOverride(params: [String: Any]) -> V2CallResult {
// Accept either:
// - state: "active" | "inactive" | "clear"
// - focused: true/false/null
if let state = v2String(params, "state")?.lowercased() {
switch state {
case "active":
AppFocusState.overrideIsFocused = true
case "inactive":
AppFocusState.overrideIsFocused = false
case "clear", "none":
AppFocusState.overrideIsFocused = nil
default:
return .err(code: "invalid_params", message: "Invalid state (active|inactive|clear)", data: ["state": state])
}
} else if params.keys.contains("focused") {
if let focused = v2Bool(params, "focused") {
AppFocusState.overrideIsFocused = focused
} else {
AppFocusState.overrideIsFocused = nil
}
} else {
return .err(code: "invalid_params", message: "Missing state or focused", data: nil)
}
let overrideVal: Any = v2OrNull(AppFocusState.overrideIsFocused.map { $0 as Any })
return .ok(["override": overrideVal])
}
private func v2AppSimulateActive() -> V2CallResult {
v2MainSync {
AppDelegate.shared?.applicationDidBecomeActive(
Notification(name: NSApplication.didBecomeActiveNotification)
)
}
return .ok([:])
}
// MARK: - V2 Browser Methods
private func v2BrowserWithPanel(
params: [String: Any],
_ body: (_ tabManager: TabManager, _ workspace: Workspace, _ surfaceId: UUID, _ browserPanel: BrowserPanel) -> V2CallResult
) -> V2CallResult {
var result: V2CallResult = .err(code: "internal_error", message: "Browser operation failed", data: nil)
v2MainSync {
guard let tabManager = v2ResolveTabManager(params: params) else {
result = .err(code: "unavailable", message: "TabManager not available", data: nil)
return
}
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused browser surface", data: nil)
return
}
guard let browserPanel = ws.browserPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString])
return
}
result = body(tabManager, ws, surfaceId, browserPanel)
}
return result
}
private func v2JSONLiteral(_ value: Any) -> String {
if let data = try? JSONSerialization.data(withJSONObject: [value], options: []),
let text = String(data: data, encoding: .utf8),
text.count >= 2 {
return String(text.dropFirst().dropLast())
}
if let s = value as? String {
return "\"\(s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\""
}
return "null"
}
private func v2NormalizeJSValue(_ value: Any?) -> Any {
guard let value else { return NSNull() }
if value is V2BrowserUndefinedSentinel {
return [
Self.v2BrowserEvalEnvelopeTypeKey: Self.v2BrowserEvalEnvelopeTypeUndefined,
Self.v2BrowserEvalEnvelopeValueKey: NSNull()
]
}
if value is NSNull { return NSNull() }
if let v = value as? String { return v }
if let v = value as? NSNumber { return v }
if let v = value as? Bool { return v }
if let dict = value as? [String: Any] {
var out: [String: Any] = [:]
for (k, v) in dict {
out[k] = v2NormalizeJSValue(v)
}
return out
}
if let arr = value as? [Any] {
return arr.map { v2NormalizeJSValue($0) }
}
return String(describing: value)
}
private enum V2JavaScriptResult {
case success(Any?)
case failure(String)
}
private func v2RunJavaScript(
_ webView: WKWebView,
script: String,
timeout: TimeInterval = 5.0,
preferAsync: Bool = false
) -> V2JavaScriptResult {
var done = false
var resultValue: Any?
var resultError: String?
if preferAsync, #available(macOS 11.0, *) {
webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
switch result {
case .success(let value):
resultValue = value
case .failure(let error):
resultError = error.localizedDescription
}
done = true
}
} else {
webView.evaluateJavaScript(script) { value, error in
if let error {
resultError = error.localizedDescription
} else {
resultValue = value
}
done = true
}
}
let deadline = Date().addingTimeInterval(timeout)
while !done && Date() < deadline {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
if !done {
return .failure("Timed out waiting for JavaScript result")
}
if let resultError {
return .failure(resultError)
}
return .success(resultValue)
}
private func v2BrowserSelector(_ params: [String: Any]) -> String? {
v2String(params, "selector")
?? v2String(params, "sel")
?? v2String(params, "element_ref")
?? v2String(params, "ref")
}
private func v2BrowserNotSupported(_ method: String, details: String) -> V2CallResult {
.err(code: "not_supported", message: "\(method) is not supported on WKWebView", data: ["details": details])
}
private func v2BrowserAllocateElementRef(surfaceId: UUID, selector: String) -> String {
let ref = "@e\(v2BrowserNextElementOrdinal)"
v2BrowserNextElementOrdinal += 1
v2BrowserElementRefs[ref] = V2BrowserElementRefEntry(surfaceId: surfaceId, selector: selector)
return ref
}
private func v2BrowserResolveSelector(_ rawSelector: String, surfaceId: UUID) -> String? {
let trimmed = rawSelector.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let refKey: String? = {
if trimmed.hasPrefix("@e") { return trimmed }
if trimmed.hasPrefix("e"), Int(trimmed.dropFirst()) != nil { return "@\(trimmed)" }
return nil
}()
if let refKey {
guard let entry = v2BrowserElementRefs[refKey], entry.surfaceId == surfaceId else { return nil }
return entry.selector
}
return trimmed
}
private func v2BrowserCurrentFrameSelector(surfaceId: UUID) -> String? {
v2BrowserFrameSelectorBySurface[surfaceId]
}
private func v2RunBrowserJavaScript(
_ webView: WKWebView,
surfaceId: UUID,
script: String,
timeout: TimeInterval = 5.0
) -> V2JavaScriptResult {
let scriptLiteral = v2JSONLiteral(script)
let framePrelude: String
if let frameSelector = v2BrowserCurrentFrameSelector(surfaceId: surfaceId) {
let selectorLiteral = v2JSONLiteral(frameSelector)
framePrelude = """
let __cmuxDoc = document;
try {
const __cmuxFrame = document.querySelector(\(selectorLiteral));
if (__cmuxFrame && __cmuxFrame.contentDocument) {
__cmuxDoc = __cmuxFrame.contentDocument;
}
} catch (_) {}
"""
} else {
framePrelude = "const __cmuxDoc = document;"
}
let asyncFunctionBody = """
\(framePrelude)
const __cmuxMaybeAwait = async (__r) => {
if (__r !== null && (typeof __r === 'object' || typeof __r === 'function') && typeof __r.then === 'function') {
return await __r;
}
return __r;
};
const __cmuxEvalInFrame = async function() {
const document = __cmuxDoc;
const __r = eval(\(scriptLiteral));
const __value = await __cmuxMaybeAwait(__r);
return {
__cmux_t: (typeof __value === 'undefined') ? 'undefined' : 'value',
__cmux_v: __value
};
};
return await __cmuxEvalInFrame();
"""
let rawResult: V2JavaScriptResult
if #available(macOS 11.0, *) {
rawResult = v2RunJavaScript(webView, script: asyncFunctionBody, timeout: timeout, preferAsync: true)
} else {
let evaluateFallback = """
(async () => {
\(asyncFunctionBody)
})()
"""
rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout)
}
switch rawResult {
case .failure(let message):
return .failure(message)
case .success(let value):
guard let dict = value as? [String: Any],
let type = dict[Self.v2BrowserEvalEnvelopeTypeKey] as? String else {
return .success(value)
}
switch type {
case Self.v2BrowserEvalEnvelopeTypeUndefined:
return .success(v2BrowserUndefinedSentinel)
case Self.v2BrowserEvalEnvelopeTypeValue:
return .success(dict[Self.v2BrowserEvalEnvelopeValueKey])
default:
return .success(value)
}
}
}
private func v2BrowserRecordUnsupportedRequest(surfaceId: UUID, request: [String: Any]) {
var logs = v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] ?? []
logs.append(request)
if logs.count > 256 {
logs.removeFirst(logs.count - 256)
}
v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] = logs
}
private func v2BrowserPendingDialogs(surfaceId: UUID) -> [[String: Any]] {
let queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
return queue.enumerated().map { index, d in
[
"index": index,
"type": d.type,
"message": d.message,
"default_text": v2OrNull(d.defaultText)
]
}
}
func enqueueBrowserDialog(
surfaceId: UUID,
type: String,
message: String,
defaultText: String?,
responder: @escaping (_ accept: Bool, _ text: String?) -> Void
) {
var queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
queue.append(V2BrowserPendingDialog(type: type, message: message, defaultText: defaultText, responder: responder))
if queue.count > 16 {
// Keep bounded memory while preserving FIFO semantics for newest entries.
queue.removeFirst(queue.count - 16)
}
v2BrowserDialogQueueBySurface[surfaceId] = queue
}
private func v2BrowserPopDialog(surfaceId: UUID) -> V2BrowserPendingDialog? {
var queue = v2BrowserDialogQueueBySurface[surfaceId] ?? []
guard !queue.isEmpty else { return nil }
let first = queue.removeFirst()
v2BrowserDialogQueueBySurface[surfaceId] = queue
return first
}
private func v2BrowserEnsureInitScriptsApplied(surfaceId: UUID, browserPanel: BrowserPanel) {
let scripts = v2BrowserInitScriptsBySurface[surfaceId] ?? []
let styles = v2BrowserInitStylesBySurface[surfaceId] ?? []
guard !scripts.isEmpty || !styles.isEmpty else { return }
let injector = """
(() => {
window.__cmuxInitScriptsApplied = window.__cmuxInitScriptsApplied || { scripts: [], styles: [] };
return true;
})()
"""
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: injector)
for script in scripts {
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script)
}
for css in styles {
let cssLiteral = v2JSONLiteral(css)
let styleScript = """
(() => {
const id = 'cmux-init-style-' + btoa(unescape(encodeURIComponent(\(cssLiteral)))).replace(/=+$/g, '');
if (document.getElementById(id)) return true;
const el = document.createElement('style');
el.id = id;
el.textContent = String(\(cssLiteral));
(document.head || document.documentElement || document.body).appendChild(el);
return true;
})()
"""
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: styleScript)
}
}
private func v2BrowserWaitForCondition(
_ conditionScript: String,
webView: WKWebView,
surfaceId: UUID? = nil,
timeout: TimeInterval = 5.0,
pollInterval: TimeInterval = 0.05
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let wrapped = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()"
let jsResult: V2JavaScriptResult
if let surfaceId {
jsResult = v2RunBrowserJavaScript(webView, surfaceId: surfaceId, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
} else {
jsResult = v2RunJavaScript(webView, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
}
if case let .success(value) = jsResult,
let ok = value as? Bool,
ok {
return true
}
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(pollInterval))
}
return false
}
private func v2PNGData(from image: NSImage) -> Data? {
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff) else { return nil }
return rep.representation(using: .png, properties: [:])
}
// MARK: - Markdown
private func v2MarkdownOpen(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let rawPath = v2String(params, "path") else {
return .err(code: "invalid_params", message: "Missing 'path' parameter", data: nil)
}
// Resolve the path (expand ~ and standardize)
let expandedPath = NSString(string: rawPath).expandingTildeInPath
let filePath = NSString(string: expandedPath).standardizingPath
// Reject paths that aren't absolute after resolution
guard filePath.hasPrefix("/") else {
return .err(code: "invalid_params", message: "Path must be absolute: \(filePath)", data: ["path": filePath])
}
// Validate the file exists and is a regular file (not a directory)
var isDir: ObjCBool = false
guard FileManager.default.fileExists(atPath: filePath, isDirectory: &isDir) else {
return .err(code: "not_found", message: "File not found: \(filePath)", data: ["path": filePath])
}
guard !isDir.boolValue else {
return .err(code: "invalid_params", message: "Path is a directory, not a file: \(filePath)", data: ["path": filePath])
}
guard FileManager.default.isReadableFile(atPath: filePath) else {
return .err(code: "permission_denied", message: "File not readable: \(filePath)", data: ["path": filePath])
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let sourceSurfaceId else {
result = .err(code: "not_found", message: "No focused surface to split", data: nil)
return
}
guard ws.panels[sourceSurfaceId] != nil else {
result = .err(code: "not_found", message: "Source surface not found", data: ["surface_id": sourceSurfaceId.uuidString])
return
}
let sourcePaneUUID = ws.paneId(forPanelId: sourceSurfaceId)?.id
let createdPanel = ws.newMarkdownSplit(
from: sourceSurfaceId,
orientation: .horizontal,
filePath: filePath,
focus: v2FocusAllowed()
)
guard let markdownPanelId = createdPanel?.id else {
result = .err(code: "internal_error", message: "Failed to create markdown panel", data: nil)
return
}
let targetPaneUUID = ws.paneId(forPanelId: markdownPanelId)?.id
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": v2OrNull(targetPaneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
"surface_id": markdownPanelId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: markdownPanelId),
"source_surface_id": sourceSurfaceId.uuidString,
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
"source_pane_id": v2OrNull(sourcePaneUUID?.uuidString),
"source_pane_ref": v2Ref(kind: .pane, uuid: sourcePaneUUID),
"target_pane_id": v2OrNull(targetPaneUUID?.uuidString),
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
"path": filePath
])
}
return result
}
// MARK: - Browser
private func v2BrowserOpenSplit(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let urlStr = v2String(params, "url")
let url = urlStr.flatMap { URL(string: $0) }
let respectExternalOpenRules = v2Bool(params, "respect_external_open_rules") ?? false
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
if let url,
respectExternalOpenRules,
BrowserLinkOpenSettings.shouldOpenExternally(url) {
guard NSWorkspace.shared.open(url) else {
result = .err(
code: "external_open_failed",
message: "Failed to open URL externally",
data: ["url": url.absoluteString]
)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": v2OrNull(nil),
"pane_ref": v2Ref(kind: .pane, uuid: nil),
"surface_id": v2OrNull(nil),
"surface_ref": v2Ref(kind: .surface, uuid: nil),
"created_split": false,
"placement_strategy": "external",
"opened_externally": true,
"url": url.absoluteString
])
return
}
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
let sourceSurfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let sourceSurfaceId else {
result = .err(code: "not_found", message: "No focused surface to split", data: nil)
return
}
guard ws.panels[sourceSurfaceId] != nil else {
result = .err(code: "not_found", message: "Source surface not found", data: ["surface_id": sourceSurfaceId.uuidString])
return
}
let sourcePaneUUID = ws.paneId(forPanelId: sourceSurfaceId)?.id
var createdSplit = true
var placementStrategy = "split_right"
let createdPanel: BrowserPanel?
if let targetPane = ws.preferredBrowserTargetPane(fromPanelId: sourceSurfaceId) {
createdPanel = ws.newBrowserSurface(inPane: targetPane, url: url, focus: v2FocusAllowed())
createdSplit = false
placementStrategy = "reuse_right_sibling"
} else {
createdPanel = ws.newBrowserSplit(
from: sourceSurfaceId,
orientation: .horizontal,
url: url,
focus: v2FocusAllowed()
)
}
guard let browserPanelId = createdPanel?.id else {
result = .err(code: "internal_error", message: "Failed to create browser", data: nil)
return
}
let targetPaneUUID = ws.paneId(forPanelId: browserPanelId)?.id
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": v2OrNull(targetPaneUUID?.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
"surface_id": browserPanelId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: browserPanelId),
"source_surface_id": sourceSurfaceId.uuidString,
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
"source_pane_id": v2OrNull(sourcePaneUUID?.uuidString),
"source_pane_ref": v2Ref(kind: .pane, uuid: sourcePaneUUID),
"target_pane_id": v2OrNull(targetPaneUUID?.uuidString),
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPaneUUID),
"created_split": createdSplit,
"placement_strategy": placementStrategy
])
}
return result
}
private func v2BrowserNavigate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
guard let url = v2String(params, "url") else {
return .err(code: "invalid_params", message: "Missing url", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
browserPanel.navigateSmart(url)
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))
]
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
result = .ok(payload)
}
return result
}
private func v2BrowserBack(params: [String: Any]) -> V2CallResult {
return v2BrowserNavSimple(params: params, action: "back")
}
private func v2BrowserForward(params: [String: Any]) -> V2CallResult {
return v2BrowserNavSimple(params: params, action: "forward")
}
private func v2BrowserReload(params: [String: Any]) -> V2CallResult {
return v2BrowserNavSimple(params: params, action: "reload")
}
private func v2BrowserNotFoundDiagnostics(
surfaceId: UUID,
browserPanel: BrowserPanel,
selector: String
) -> [String: Any] {
let selectorLiteral = v2JSONLiteral(selector)
let script = """
(() => {
const __selector = \(selectorLiteral);
const __normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
const __isVisible = (el) => {
try {
if (!el) return false;
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (!style || !rect) return false;
if (rect.width <= 0 || rect.height <= 0) return false;
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (parseFloat(style.opacity || '1') <= 0.01) return false;
return true;
} catch (_) {
return false;
}
};
const __describe = (el) => {
const tag = String(el.tagName || '').toLowerCase();
const id = __normalize(el.id || '');
const klass = __normalize(el.className || '').split(/\\s+/).filter(Boolean).slice(0, 2).join('.');
let out = tag || 'element';
if (id) out += '#' + id;
if (klass) out += '.' + klass;
return out;
};
try {
const __nodes = Array.from(document.querySelectorAll(__selector));
const __visible = __nodes.filter(__isVisible);
const __sample = __nodes.slice(0, 6).map((el, idx) => ({
index: idx,
descriptor: __describe(el),
role: __normalize(el.getAttribute('role') || ''),
visible: __isVisible(el),
text: __normalize(el.innerText || el.textContent || '').slice(0, 120)
}));
const __snapshotExcerpt = __sample.map((row) => {
const suffix = row.text ? ` \"${row.text}\"` : '';
return `- ${row.descriptor}${suffix}`;
}).join('\\n');
return {
ok: true,
selector: __selector,
count: __nodes.length,
visible_count: __visible.length,
sample: __sample,
snapshot_excerpt: __snapshotExcerpt,
title: __normalize(document.title || ''),
url: String(location.href || ''),
body_excerpt: document.body ? __normalize(document.body.innerText || '').slice(0, 400) : ''
};
} catch (err) {
return {
ok: false,
selector: __selector,
error: 'invalid_selector',
details: String((err && err.message) || err || '')
};
}
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 4.0) {
case .failure(let message):
return [
"selector": selector,
"diagnostics_error": message
]
case .success(let value):
guard let dict = value as? [String: Any] else {
return ["selector": selector]
}
var out: [String: Any] = ["selector": selector]
if let count = dict["count"] { out["match_count"] = count }
if let visibleCount = dict["visible_count"] { out["visible_match_count"] = visibleCount }
if let sample = dict["sample"] { out["sample"] = v2NormalizeJSValue(sample) }
if let excerpt = dict["snapshot_excerpt"] { out["snapshot_excerpt"] = excerpt }
if let body = dict["body_excerpt"] { out["body_excerpt"] = body }
if let title = dict["title"] { out["title"] = title }
if let url = dict["url"] { out["url"] = url }
if let err = dict["error"] { out["diagnostics_code"] = err }
if let details = dict["details"] { out["diagnostics_details"] = details }
return out
}
}
private func v2BrowserElementNotFoundResult(
actionName: String,
selector: String,
attempts: Int,
surfaceId: UUID,
browserPanel: BrowserPanel
) -> V2CallResult {
var data = v2BrowserNotFoundDiagnostics(surfaceId: surfaceId, browserPanel: browserPanel, selector: selector)
data["action"] = actionName
data["retry_attempts"] = attempts
data["hint"] = "Run 'browser snapshot' to refresh refs, then retry with a more specific selector."
let count = (data["match_count"] as? Int) ?? (data["match_count"] as? NSNumber)?.intValue ?? 0
let visibleCount = (data["visible_match_count"] as? Int) ?? (data["visible_match_count"] as? NSNumber)?.intValue ?? 0
let message: String
if count > 0 && visibleCount == 0 {
message = "Element \"\(selector)\" is present but not visible."
} else if count > 1 {
message = "Selector \"\(selector)\" matched multiple elements."
} else {
message = "Element \"\(selector)\" not found or not visible. Run 'browser snapshot' to see current page elements."
}
return .err(code: "not_found", message: message, data: data)
}
private func v2BrowserAppendPostSnapshot(
params: [String: Any],
surfaceId: UUID,
payload: inout [String: Any]
) {
guard v2Bool(params, "snapshot_after") ?? false else { return }
var snapshotParams: [String: Any] = [
"surface_id": surfaceId.uuidString,
"interactive": v2Bool(params, "snapshot_interactive") ?? true,
"cursor": v2Bool(params, "snapshot_cursor") ?? false,
"compact": v2Bool(params, "snapshot_compact") ?? true,
"max_depth": max(0, v2Int(params, "snapshot_max_depth") ?? 10)
]
if let selector = v2String(params, "snapshot_selector"),
!selector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
snapshotParams["selector"] = selector
}
switch v2BrowserSnapshot(params: snapshotParams) {
case .ok(let snapshotAny):
guard let snapshot = snapshotAny as? [String: Any] else {
payload["post_action_snapshot_error"] = [
"code": "internal_error",
"message": "Invalid snapshot payload"
]
return
}
if let value = snapshot["snapshot"] {
payload["post_action_snapshot"] = value
}
if let value = snapshot["refs"] {
payload["post_action_refs"] = value
}
if let value = snapshot["title"] {
payload["post_action_title"] = value
}
if let value = snapshot["url"] {
payload["post_action_url"] = value
}
case .err(code: let code, message: let message, data: let data):
var err: [String: Any] = [
"code": code,
"message": message,
]
err["data"] = v2OrNull(data)
payload["post_action_snapshot_error"] = err
}
}
private func v2BrowserSelectorAction(
params: [String: Any],
actionName: String,
scriptBuilder: (_ selectorLiteral: String) -> String
) -> V2CallResult {
guard let selectorRaw = v2BrowserSelector(params) else {
return .err(code: "invalid_params", message: "Missing selector", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
}
let script = scriptBuilder(v2JSONLiteral(selector))
let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3)
for attempt in 1...retryAttempts {
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector])
case .success(let value):
if let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok {
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"surface_id": surfaceId.uuidString,
"action": actionName,
"attempts": attempt
]
payload["workspace_ref"] = v2Ref(kind: .workspace, uuid: ws.id)
payload["surface_ref"] = v2Ref(kind: .surface, uuid: surfaceId)
if let resultValue = dict["value"] {
payload["value"] = v2NormalizeJSValue(resultValue)
}
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
return .ok(payload)
}
let errorText = (value as? [String: Any])?["error"] as? String
if errorText == "not_found", attempt < retryAttempts {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.08))
continue
}
if errorText == "not_found" {
return v2BrowserElementNotFoundResult(
actionName: actionName,
selector: selector,
attempts: retryAttempts,
surfaceId: surfaceId,
browserPanel: browserPanel
)
}
return .err(code: "js_error", message: "Browser action failed", data: ["action": actionName, "selector": selector])
}
}
return v2BrowserElementNotFoundResult(
actionName: actionName,
selector: selector,
attempts: retryAttempts,
surfaceId: surfaceId,
browserPanel: browserPanel
)
}
}
private func v2BrowserEval(params: [String: Any]) -> V2CallResult {
guard let script = v2String(params, "script") else {
return .err(code: "invalid_params", message: "Missing script", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"value": v2NormalizeJSValue(value)
])
}
}
}
private func v2BrowserSnapshot(params: [String: Any]) -> V2CallResult {
let interactiveOnly = v2Bool(params, "interactive") ?? false
let includeCursor = v2Bool(params, "cursor") ?? false
let compact = v2Bool(params, "compact") ?? false
let maxDepth = max(0, v2Int(params, "max_depth") ?? v2Int(params, "maxDepth") ?? 12)
let scopeSelector = v2String(params, "selector")
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let interactiveLiteral = interactiveOnly ? "true" : "false"
let cursorLiteral = includeCursor ? "true" : "false"
let compactLiteral = compact ? "true" : "false"
let scopeLiteral = scopeSelector.map(v2JSONLiteral) ?? "null"
let script = """
(() => {
const __interactiveOnly = \(interactiveLiteral);
const __includeCursor = \(cursorLiteral);
const __compact = \(compactLiteral);
const __maxDepth = \(maxDepth);
const __scopeSelector = \(scopeLiteral);
const __normalize = (s) => String(s || '').replace(/\\s+/g, ' ').trim();
const __interactiveRoles = new Set(['button','link','textbox','checkbox','radio','combobox','listbox','menuitem','menuitemcheckbox','menuitemradio','option','searchbox','slider','spinbutton','switch','tab','treeitem']);
const __contentRoles = new Set(['heading','cell','gridcell','columnheader','rowheader','listitem','article','region','main','navigation']);
const __structuralRoles = new Set(['generic','group','list','table','row','rowgroup','grid','treegrid','menu','menubar','toolbar','tablist','tree','directory','document','application','presentation','none']);
const __isVisible = (el) => {
try {
if (!el) return false;
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
if (!style || !rect) return false;
if (rect.width <= 0 || rect.height <= 0) return false;
if (style.display === 'none' || style.visibility === 'hidden') return false;
if (parseFloat(style.opacity || '1') <= 0.01) return false;
return true;
} catch (_) {
return false;
}
};
const __implicitRole = (el) => {
const tag = String(el.tagName || '').toLowerCase();
if (tag === 'button') return 'button';
if (tag === 'a' && el.hasAttribute('href')) return 'link';
if (tag === 'input') {
const type = String(el.getAttribute('type') || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'submit' || type === 'button' || type === 'reset') return 'button';
return 'textbox';
}
if (tag === 'textarea') return 'textbox';
if (tag === 'select') return 'combobox';
if (tag === 'summary') return 'button';
if (tag === 'h1' || tag === 'h2' || tag === 'h3' || tag === 'h4' || tag === 'h5' || tag === 'h6') return 'heading';
if (tag === 'li') return 'listitem';
return null;
};
const __nameFor = (el) => {
const aria = __normalize(el.getAttribute('aria-label') || '');
if (aria) return aria;
const labelledBy = __normalize(el.getAttribute('aria-labelledby') || '');
if (labelledBy) {
const text = labelledBy.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((n) => __normalize(n.textContent || '')).join(' ').trim();
if (text) return text;
}
if (el.tagName && String(el.tagName).toLowerCase() === 'input') {
const placeholder = __normalize(el.getAttribute('placeholder') || '');
if (placeholder) return placeholder;
const value = __normalize(el.value || '');
if (value) return value;
}
const title = __normalize(el.getAttribute('title') || '');
if (title) return title;
const text = __normalize(el.innerText || el.textContent || '');
if (text) return text.slice(0, 120);
return '';
};
const __cssPath = (el) => {
if (!el || el.nodeType !== 1) return null;
if (el.id) return '#' + CSS.escape(el.id);
const parts = [];
let cur = el;
while (cur && cur.nodeType === 1) {
let part = String(cur.tagName || '').toLowerCase();
if (!part) break;
if (cur.id) {
part += '#' + CSS.escape(cur.id);
parts.unshift(part);
break;
}
const tag = part;
const parent = cur.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((n) => String(n.tagName || '').toLowerCase() === tag);
if (siblings.length > 1) {
const index = siblings.indexOf(cur) + 1;
part += `:nth-of-type(${index})`;
}
}
parts.unshift(part);
cur = cur.parentElement;
if (parts.length >= 6) break;
}
return parts.join(' > ');
};
const __root = (() => {
if (__scopeSelector) {
return document.querySelector(__scopeSelector) || document.body || document.documentElement;
}
return document.body || document.documentElement;
})();
const __entries = [];
const __seen = new Set();
const __appendEntry = (el, depth, forcedRole) => {
if (!__isVisible(el)) return;
const explicitRole = __normalize(el.getAttribute('role') || '').toLowerCase();
const role = forcedRole || explicitRole || __implicitRole(el) || '';
if (!role) return;
if (__interactiveOnly && !__interactiveRoles.has(role)) return;
if (!__interactiveOnly) {
const includeRole = __interactiveRoles.has(role) || __contentRoles.has(role);
if (!includeRole) return;
if (__compact && __structuralRoles.has(role)) {
const name = __nameFor(el);
if (!name) return;
}
}
const selector = __cssPath(el);
if (!selector || __seen.has(selector)) return;
__seen.add(selector);
__entries.push({
selector,
role,
name: __nameFor(el),
depth
});
};
const __walk = (node, depth) => {
if (!node || depth > __maxDepth || node.nodeType !== 1) return;
const el = node;
__appendEntry(el, depth, null);
for (const child of Array.from(el.children || [])) {
__walk(child, depth + 1);
}
};
if (__root) {
__walk(__root, 0);
}
if (__includeCursor && __root) {
const all = Array.from(__root.querySelectorAll('*'));
for (const el of all) {
if (!__isVisible(el)) continue;
const style = getComputedStyle(el);
const hasOnClick = typeof el.onclick === 'function' || el.hasAttribute('onclick');
const hasCursorPointer = style.cursor === 'pointer';
const tabIndex = el.getAttribute('tabindex');
const hasTabIndex = tabIndex != null && String(tabIndex) !== '-1';
if (!hasOnClick && !hasCursorPointer && !hasTabIndex) continue;
__appendEntry(el, 0, 'generic');
if (__entries.length >= 256) break;
}
}
const body = document.body;
const root = document.documentElement;
return {
title: __normalize(document.title || ''),
url: String(location.href || ''),
ready_state: String(document.readyState || ''),
text: body ? String(body.innerText || '') : '',
html: root ? String(root.outerHTML || '') : '',
entries: __entries
};
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any] else {
return .err(code: "js_error", message: "Invalid snapshot payload", data: nil)
}
let title = (dict["title"] as? String) ?? ""
let url = (dict["url"] as? String) ?? ""
let readyState = (dict["ready_state"] as? String) ?? ""
let text = (dict["text"] as? String) ?? ""
let html = (dict["html"] as? String) ?? ""
let entries = (dict["entries"] as? [[String: Any]]) ?? []
var refs: [String: [String: Any]] = [:]
var treeLines: [String] = []
var seenSelectors: Set<String> = []
for entry in entries {
guard let selector = entry["selector"] as? String,
!selector.isEmpty,
!seenSelectors.contains(selector) else {
continue
}
seenSelectors.insert(selector)
let roleRaw = (entry["role"] as? String) ?? "generic"
let role = roleRaw.isEmpty ? "generic" : roleRaw
let name = ((entry["name"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let depth = max(0, (entry["depth"] as? Int) ?? ((entry["depth"] as? NSNumber)?.intValue ?? 0))
let refToken = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector)
let shortRef = refToken.hasPrefix("@") ? String(refToken.dropFirst()) : refToken
var refInfo: [String: Any] = ["role": role]
if !name.isEmpty {
refInfo["name"] = name
}
refs[shortRef] = refInfo
let indent = String(repeating: " ", count: depth)
var line = "\(indent)- \(role)"
if !name.isEmpty {
let cleanName = name.replacingOccurrences(of: "\"", with: "'")
line += " \"\(cleanName)\""
}
line += " [ref=\(shortRef)]"
treeLines.append(line)
}
let titleForTree = title.isEmpty ? "page" : title.replacingOccurrences(of: "\"", with: "'")
var snapshotLines = ["- document \"\(titleForTree)\""]
if !treeLines.isEmpty {
snapshotLines.append(contentsOf: treeLines)
} else {
let excerpt = text
.replacingOccurrences(of: "\n", with: " ")
.replacingOccurrences(of: "\t", with: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
if !excerpt.isEmpty {
let clipped = String(excerpt.prefix(240)).replacingOccurrences(of: "\"", with: "'")
snapshotLines.append("- text \"\(clipped)\"")
} else {
snapshotLines.append("- (empty)")
}
}
let snapshotText = snapshotLines.joined(separator: "\n")
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"snapshot": snapshotText,
"title": title,
"url": url,
"ready_state": readyState,
"page": [
"title": title,
"url": url,
"ready_state": readyState,
"text": text,
"html": html
]
]
if !refs.isEmpty {
payload["refs"] = refs
}
return .ok(payload)
}
}
}
private func v2BrowserWait(params: [String: Any]) -> V2CallResult {
let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000)
let timeout = Double(timeoutMs) / 1000.0
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let conditionScript: String = {
if let selector = v2BrowserSelector(params) {
let literal = v2JSONLiteral(selector)
return "document.querySelector(\(literal)) !== null"
}
if let urlContains = v2String(params, "url_contains") {
let literal = v2JSONLiteral(urlContains)
return "String(location.href || '').includes(\(literal))"
}
if let textContains = v2String(params, "text_contains") {
let literal = v2JSONLiteral(textContains)
return "(document.body && String(document.body.innerText || '').includes(\(literal)))"
}
if let loadState = v2String(params, "load_state") {
let literal = v2JSONLiteral(loadState.lowercased())
return "String(document.readyState || '').toLowerCase() === \(literal)"
}
if let fn = v2String(params, "function") {
return "(() => { return !!(\(fn)); })()"
}
return "document.readyState === 'complete'"
}()
let ok = v2BrowserWaitForCondition(conditionScript, webView: browserPanel.webView, surfaceId: surfaceId, timeout: timeout)
if !ok {
return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs])
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"waited": true
])
}
}
private func v2BrowserClick(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "click") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
if (typeof el.click === 'function') {
el.click();
} else {
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window, detail: 1 }));
}
return { ok: true };
})()
"""
}
}
private func v2BrowserDblClick(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "dblclick") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window, detail: 2 }));
return { ok: true };
})()
"""
}
}
private func v2BrowserHover(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "hover") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
el.scrollIntoView({ block: 'nearest', inline: 'nearest' });
el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window }));
el.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true, view: window }));
return { ok: true };
})()
"""
}
}
private func v2BrowserFocusElement(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "focus") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
if (typeof el.focus === 'function') el.focus();
return { ok: true };
})()
"""
}
}
private func v2BrowserType(params: [String: Any]) -> V2CallResult {
guard let text = v2String(params, "text") else {
return .err(code: "invalid_params", message: "Missing text", data: nil)
}
return v2BrowserSelectorAction(params: params, actionName: "type") { selectorLiteral in
let textLiteral = v2JSONLiteral(text)
return """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
if (typeof el.focus === 'function') el.focus();
const chunk = String(\(textLiteral));
if ('value' in el) {
el.value = (el.value || '') + chunk;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
} else {
el.textContent = (el.textContent || '') + chunk;
}
return { ok: true };
})()
"""
}
}
private func v2BrowserFill(params: [String: Any]) -> V2CallResult {
// `fill` must allow empty strings so callers can clear existing input values.
guard let text = v2RawString(params, "text") ?? v2RawString(params, "value") else {
return .err(code: "invalid_params", message: "Missing text/value", data: nil)
}
return v2BrowserSelectorAction(params: params, actionName: "fill") { selectorLiteral in
let textLiteral = v2JSONLiteral(text)
return """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
if (typeof el.focus === 'function') el.focus();
const value = String(\(textLiteral));
if ('value' in el) {
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
} else {
el.textContent = value;
}
return { ok: true };
})()
"""
}
}
private func v2BrowserPress(params: [String: Any]) -> V2CallResult {
guard let key = v2String(params, "key") else {
return .err(code: "invalid_params", message: "Missing key", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let keyLiteral = v2JSONLiteral(key)
let script = """
(() => {
const target = document.activeElement || document.body || document.documentElement;
if (!target) return { ok: false, error: 'not_found' };
const k = String(\(keyLiteral));
target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
target.dispatchEvent(new KeyboardEvent('keypress', { key: k, bubbles: true, cancelable: true }));
target.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true, cancelable: true }));
return { ok: true };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success:
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
]
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
return .ok(payload)
}
}
}
private func v2BrowserKeyDown(params: [String: Any]) -> V2CallResult {
guard let key = v2String(params, "key") else {
return .err(code: "invalid_params", message: "Missing key", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let keyLiteral = v2JSONLiteral(key)
let script = """
(() => {
const target = document.activeElement || document.body || document.documentElement;
if (!target) return { ok: false, error: 'not_found' };
const k = String(\(keyLiteral));
target.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true }));
return { ok: true };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success:
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
]
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
return .ok(payload)
}
}
}
private func v2BrowserKeyUp(params: [String: Any]) -> V2CallResult {
guard let key = v2String(params, "key") else {
return .err(code: "invalid_params", message: "Missing key", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let keyLiteral = v2JSONLiteral(key)
let script = """
(() => {
const target = document.activeElement || document.body || document.documentElement;
if (!target) return { ok: false, error: 'not_found' };
const k = String(\(keyLiteral));
target.dispatchEvent(new KeyboardEvent('keyup', { key: k, bubbles: true, cancelable: true }));
return { ok: true };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success:
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
]
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
return .ok(payload)
}
}
}
private func v2BrowserCheck(params: [String: Any], checked: Bool) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: checked ? "check" : "uncheck") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
if (!('checked' in el)) return { ok: false, error: 'not_checkable' };
el.checked = \(checked ? "true" : "false");
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
})()
"""
}
}
private func v2BrowserSelect(params: [String: Any]) -> V2CallResult {
let selectedValue = v2String(params, "value") ?? v2String(params, "text")
guard let selectedValue else {
return .err(code: "invalid_params", message: "Missing value", data: nil)
}
return v2BrowserSelectorAction(params: params, actionName: "select") { selectorLiteral in
let valueLiteral = v2JSONLiteral(selectedValue)
return """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
if (!('value' in el)) return { ok: false, error: 'not_select' };
el.value = String(\(valueLiteral));
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
return { ok: true };
})()
"""
}
}
private func v2BrowserScroll(params: [String: Any]) -> V2CallResult {
let dx = v2Int(params, "dx") ?? 0
let dy = v2Int(params, "dy") ?? 0
let selectorRaw = v2BrowserSelector(params)
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let selector = selectorRaw.flatMap { v2BrowserResolveSelector($0, surfaceId: surfaceId) }
if selectorRaw != nil && selector == nil {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw ?? ""])
}
let script: String
if let selector {
let selectorLiteral = v2JSONLiteral(selector)
script = """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
if (typeof el.scrollBy === 'function') {
el.scrollBy({ left: \(dx), top: \(dy), behavior: 'instant' });
} else {
el.scrollLeft += \(dx);
el.scrollTop += \(dy);
}
return { ok: true };
})()
"""
} else {
script = "window.scrollBy({ left: \(dx), top: \(dy), behavior: 'instant' }); ({ ok: true })"
}
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
if let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
!ok,
let errorText = dict["error"] as? String,
errorText == "not_found" {
if let selector {
return v2BrowserElementNotFoundResult(
actionName: "scroll",
selector: selector,
attempts: 1,
surfaceId: surfaceId,
browserPanel: browserPanel
)
}
return .err(code: "not_found", message: "Element not found", data: ["selector": selector ?? ""])
}
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
]
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
return .ok(payload)
}
}
}
private func v2BrowserScrollIntoView(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "scroll_into_view") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
return { ok: true };
})()
"""
}
}
private func v2BrowserScreenshot(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
var done = false
var imageData: Data?
browserPanel.takeSnapshot { image in
imageData = image.flatMap { self.v2PNGData(from: $0) }
done = true
}
let deadline = Date().addingTimeInterval(5.0)
while !done && Date() < deadline {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
guard done else {
return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil)
}
guard let imageData else {
return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil)
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"png_base64": imageData.base64EncodedString()
])
}
}
private func v2BrowserGetText(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "get.text") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
return { ok: true, value: String(el.innerText || el.textContent || '') };
})()
"""
}
}
private func v2BrowserGetHTML(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "get.html") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
return { ok: true, value: String(el.outerHTML || '') };
})()
"""
}
}
private func v2BrowserGetValue(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "get.value") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const value = ('value' in el) ? el.value : (el.textContent || '');
return { ok: true, value: String(value || '') };
})()
"""
}
}
private func v2BrowserGetAttr(params: [String: Any]) -> V2CallResult {
guard let attr = v2String(params, "attr") ?? v2String(params, "name") else {
return .err(code: "invalid_params", message: "Missing attr/name", data: nil)
}
return v2BrowserSelectorAction(params: params, actionName: "get.attr") { selectorLiteral in
let attrLiteral = v2JSONLiteral(attr)
return """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
return { ok: true, value: el.getAttribute(String(\(attrLiteral))) };
})()
"""
}
}
private func v2BrowserGetTitle(params: [String: Any]) -> V2CallResult {
v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
.ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"title": browserPanel.pageTitle
])
}
}
private func v2BrowserGetCount(params: [String: Any]) -> V2CallResult {
guard let selectorRaw = v2BrowserSelector(params) else {
return .err(code: "invalid_params", message: "Missing selector", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
}
let selectorLiteral = v2JSONLiteral(selector)
let script = "document.querySelectorAll(\(selectorLiteral)).length"
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
let count = (value as? NSNumber)?.intValue ?? 0
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"count": count
])
}
}
}
private func v2BrowserGetBox(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "get.box") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const r = el.getBoundingClientRect();
return { ok: true, value: { x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, left: r.left, right: r.right, bottom: r.bottom } };
})()
"""
}
}
private func v2BrowserGetStyles(params: [String: Any]) -> V2CallResult {
let property = v2String(params, "property")
return v2BrowserSelectorAction(params: params, actionName: "get.styles") { selectorLiteral in
if let property {
let propLiteral = v2JSONLiteral(property)
return """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const style = getComputedStyle(el);
return { ok: true, value: style.getPropertyValue(String(\(propLiteral))) };
})()
"""
}
return """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const style = getComputedStyle(el);
return { ok: true, value: {
display: style.display,
visibility: style.visibility,
opacity: style.opacity,
color: style.color,
background: style.background,
width: style.width,
height: style.height
} };
})()
"""
}
}
private func v2BrowserIsVisible(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "is.visible") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect();
const visible = style.display !== 'none' && style.visibility !== 'hidden' && parseFloat(style.opacity || '1') > 0 && rect.width > 0 && rect.height > 0;
return { ok: true, value: visible };
})()
"""
}
}
private func v2BrowserIsEnabled(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "is.enabled") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const enabled = !el.disabled;
return { ok: true, value: !!enabled };
})()
"""
}
}
private func v2BrowserIsChecked(params: [String: Any]) -> V2CallResult {
v2BrowserSelectorAction(params: params, actionName: "is.checked") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const checked = ('checked' in el) ? !!el.checked : false;
return { ok: true, value: checked };
})()
"""
}
}
private func v2BrowserNavSimple(params: [String: Any], action: String) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
switch action {
case "back":
browserPanel.goBack()
case "forward":
browserPanel.goForward()
case "reload":
browserPanel.reload()
default:
break
}
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"window_id": v2OrNull(v2ResolveWindowId(tabManager: tabManager)?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: v2ResolveWindowId(tabManager: tabManager))
]
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
result = .ok(payload)
}
return result
}
private func v2BrowserGetURL(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
result = .ok([
"workspace_id": ws.id.uuidString,
"surface_id": surfaceId.uuidString,
"url": browserPanel.currentURL?.absoluteString ?? ""
])
}
return result
}
private func v2BrowserFocusWebView(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
v2MaybeFocusWindow(for: tabManager)
v2MaybeSelectWorkspace(tabManager, workspace: ws)
// Prevent omnibar auto-focus from immediately stealing first responder back.
browserPanel.suppressOmnibarAutofocus(for: 1.0)
let webView = browserPanel.webView
guard let window = webView.window else {
result = .err(code: "invalid_state", message: "WebView is not in a window", data: nil)
return
}
guard !webView.isHiddenOrHasHiddenAncestor else {
result = .err(code: "invalid_state", message: "WebView is hidden", data: nil)
return
}
window.makeFirstResponder(webView)
if let fr = window.firstResponder as? NSView, fr.isDescendant(of: webView) {
result = .ok(["focused": true])
} else {
result = .err(code: "internal_error", message: "Focus did not move into web view", data: nil)
}
}
return result
}
private func v2BrowserIsWebViewFocused(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
}
var focused = false
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager),
let browserPanel = ws.browserPanel(for: surfaceId) else { return }
let webView = browserPanel.webView
guard let window = webView.window,
let fr = window.firstResponder as? NSView else {
focused = false
return
}
focused = fr.isDescendant(of: webView)
}
return .ok(["focused": focused])
}
private func v2BrowserFindWithScript(
params: [String: Any],
actionName: String,
finderBody: String,
metadata: [String: Any] = [:]
) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let script = """
(() => {
const __cmuxCssPath = (el) => {
if (!el || el.nodeType !== 1) return null;
if (el.id) return '#' + CSS.escape(el.id);
const parts = [];
let cur = el;
while (cur && cur.nodeType === 1) {
let part = String(cur.tagName || '').toLowerCase();
if (!part) break;
if (cur.id) {
part += '#' + CSS.escape(cur.id);
parts.unshift(part);
break;
}
const tag = part;
let siblings = cur.parentElement ? Array.from(cur.parentElement.children).filter((n) => String(n.tagName || '').toLowerCase() === tag) : [];
if (siblings.length > 1) {
const pos = siblings.indexOf(cur) + 1;
part += `:nth-of-type(${pos})`;
}
parts.unshift(part);
cur = cur.parentElement;
}
return parts.join(' > ');
};
const __cmuxFound = (() => {
\(finderBody)
})();
if (!__cmuxFound) return { ok: false, error: 'not_found' };
const selector = __cmuxCssPath(__cmuxFound);
if (!selector) return { ok: false, error: 'not_found' };
return {
ok: true,
selector,
tag: String(__cmuxFound.tagName || '').toLowerCase(),
text: String(__cmuxFound.textContent || '').trim()
};
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: ["action": actionName])
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok,
let selector = dict["selector"] as? String,
!selector.isEmpty else {
return .err(code: "not_found", message: "Element not found", data: metadata)
}
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector)
var payload: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"action": actionName,
"selector": selector,
"element_ref": ref,
"ref": ref
]
for (k, v) in metadata {
payload[k] = v
}
if let tag = dict["tag"] as? String {
payload["tag"] = tag
}
if let text = dict["text"] as? String {
payload["text"] = text
}
return .ok(payload)
}
}
}
private func v2BrowserFindRole(params: [String: Any]) -> V2CallResult {
guard let role = (v2String(params, "role") ?? v2String(params, "value"))?.lowercased() else {
return .err(code: "invalid_params", message: "Missing role", data: nil)
}
let name = v2String(params, "name")?.lowercased()
let exact = v2Bool(params, "exact") ?? false
let roleLiteral = v2JSONLiteral(role)
let nameLiteral = name.map(v2JSONLiteral) ?? "null"
let exactLiteral = exact ? "true" : "false"
let finder = """
const __targetRole = String(\(roleLiteral)).toLowerCase();
const __targetName = \(nameLiteral);
const __exact = \(exactLiteral);
const __implicitRole = (el) => {
const tag = String(el.tagName || '').toLowerCase();
if (tag === 'button') return 'button';
if (tag === 'a' && el.hasAttribute('href')) return 'link';
if (tag === 'input') {
const type = String(el.getAttribute('type') || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'submit' || type === 'button') return 'button';
return 'textbox';
}
if (tag === 'textarea') return 'textbox';
if (tag === 'select') return 'combobox';
return null;
};
const __nameFor = (el) => {
const aria = String(el.getAttribute('aria-label') || '').trim();
if (aria) return aria.toLowerCase();
const labelledBy = String(el.getAttribute('aria-labelledby') || '').trim();
if (labelledBy) {
const text = labelledBy.split(/\\s+/).map((id) => document.getElementById(id)).filter(Boolean).map((n) => String(n.textContent || '').trim()).join(' ').trim();
if (text) return text.toLowerCase();
}
const txt = String(el.innerText || el.textContent || '').trim();
if (txt) return txt.toLowerCase();
if ('value' in el) {
const v = String(el.value || '').trim();
if (v) return v.toLowerCase();
}
return '';
};
const __nodes = Array.from(document.querySelectorAll('*'));
return __nodes.find((el) => {
const explicit = String(el.getAttribute('role') || '').toLowerCase();
const resolved = explicit || __implicitRole(el) || '';
if (resolved !== __targetRole) return false;
if (__targetName == null) return true;
const currentName = __nameFor(el);
return __exact ? (currentName === __targetName) : currentName.includes(__targetName);
}) || null;
"""
return v2BrowserFindWithScript(
params: params,
actionName: "find.role",
finderBody: finder,
metadata: [
"role": role,
"name": v2OrNull(name),
"exact": exact
]
)
}
private func v2BrowserFindText(params: [String: Any]) -> V2CallResult {
guard let text = (v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
return .err(code: "invalid_params", message: "Missing text", data: nil)
}
let exact = v2Bool(params, "exact") ?? false
let textLiteral = v2JSONLiteral(text)
let exactLiteral = exact ? "true" : "false"
let finder = """
const __target = String(\(textLiteral));
const __exact = \(exactLiteral);
const __norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const __nodes = Array.from(document.querySelectorAll('body *'));
return __nodes.find((el) => {
const v = __norm(el.innerText || el.textContent || '');
if (!v) return false;
return __exact ? (v === __target) : v.includes(__target);
}) || null;
"""
return v2BrowserFindWithScript(
params: params,
actionName: "find.text",
finderBody: finder,
metadata: ["text": text, "exact": exact]
)
}
private func v2BrowserFindLabel(params: [String: Any]) -> V2CallResult {
guard let label = (v2String(params, "label") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
return .err(code: "invalid_params", message: "Missing label", data: nil)
}
let exact = v2Bool(params, "exact") ?? false
let labelLiteral = v2JSONLiteral(label)
let exactLiteral = exact ? "true" : "false"
let finder = """
const __target = String(\(labelLiteral));
const __exact = \(exactLiteral);
const __norm = (s) => String(s || '').replace(/\\s+/g, ' ').trim().toLowerCase();
const __labels = Array.from(document.querySelectorAll('label'));
const __label = __labels.find((el) => {
const v = __norm(el.innerText || el.textContent || '');
return __exact ? (v === __target) : v.includes(__target);
});
if (!__label) return null;
const htmlFor = String(__label.getAttribute('for') || '').trim();
if (htmlFor) {
return document.getElementById(htmlFor);
}
return __label.querySelector('input,textarea,select,button,[contenteditable="true"]');
"""
return v2BrowserFindWithScript(
params: params,
actionName: "find.label",
finderBody: finder,
metadata: ["label": label, "exact": exact]
)
}
private func v2BrowserFindPlaceholder(params: [String: Any]) -> V2CallResult {
guard let placeholder = (v2String(params, "placeholder") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
return .err(code: "invalid_params", message: "Missing placeholder", data: nil)
}
let exact = v2Bool(params, "exact") ?? false
let placeholderLiteral = v2JSONLiteral(placeholder)
let exactLiteral = exact ? "true" : "false"
let finder = """
const __target = String(\(placeholderLiteral));
const __exact = \(exactLiteral);
const __nodes = Array.from(document.querySelectorAll('[placeholder]'));
return __nodes.find((el) => {
const p = String(el.getAttribute('placeholder') || '').trim().toLowerCase();
if (!p) return false;
return __exact ? (p === __target) : p.includes(__target);
}) || null;
"""
return v2BrowserFindWithScript(
params: params,
actionName: "find.placeholder",
finderBody: finder,
metadata: ["placeholder": placeholder, "exact": exact]
)
}
private func v2BrowserFindAlt(params: [String: Any]) -> V2CallResult {
guard let alt = (v2String(params, "alt") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
return .err(code: "invalid_params", message: "Missing alt text", data: nil)
}
let exact = v2Bool(params, "exact") ?? false
let altLiteral = v2JSONLiteral(alt)
let exactLiteral = exact ? "true" : "false"
let finder = """
const __target = String(\(altLiteral));
const __exact = \(exactLiteral);
const __nodes = Array.from(document.querySelectorAll('[alt]'));
return __nodes.find((el) => {
const a = String(el.getAttribute('alt') || '').trim().toLowerCase();
if (!a) return false;
return __exact ? (a === __target) : a.includes(__target);
}) || null;
"""
return v2BrowserFindWithScript(
params: params,
actionName: "find.alt",
finderBody: finder,
metadata: ["alt": alt, "exact": exact]
)
}
private func v2BrowserFindTitle(params: [String: Any]) -> V2CallResult {
guard let title = (v2String(params, "title") ?? v2String(params, "text") ?? v2String(params, "value"))?.lowercased() else {
return .err(code: "invalid_params", message: "Missing title", data: nil)
}
let exact = v2Bool(params, "exact") ?? false
let titleLiteral = v2JSONLiteral(title)
let exactLiteral = exact ? "true" : "false"
let finder = """
const __target = String(\(titleLiteral));
const __exact = \(exactLiteral);
const __nodes = Array.from(document.querySelectorAll('[title]'));
return __nodes.find((el) => {
const t = String(el.getAttribute('title') || '').trim().toLowerCase();
if (!t) return false;
return __exact ? (t === __target) : t.includes(__target);
}) || null;
"""
return v2BrowserFindWithScript(
params: params,
actionName: "find.title",
finderBody: finder,
metadata: ["title": title, "exact": exact]
)
}
private func v2BrowserFindTestId(params: [String: Any]) -> V2CallResult {
guard let testId = v2String(params, "testid") ?? v2String(params, "test_id") ?? v2String(params, "value") else {
return .err(code: "invalid_params", message: "Missing testid", data: nil)
}
let testIdLiteral = v2JSONLiteral(testId)
let finder = """
const __target = String(\(testIdLiteral));
const __selectors = ['[data-testid]', '[data-test-id]', '[data-test]'];
for (const sel of __selectors) {
const nodes = Array.from(document.querySelectorAll(sel));
const found = nodes.find((el) => {
return String(el.getAttribute('data-testid') || el.getAttribute('data-test-id') || el.getAttribute('data-test') || '') === __target;
});
if (found) return found;
}
return null;
"""
return v2BrowserFindWithScript(
params: params,
actionName: "find.testid",
finderBody: finder,
metadata: ["testid": testId]
)
}
private func v2BrowserFindFirst(params: [String: Any]) -> V2CallResult {
guard let selectorRaw = v2BrowserSelector(params) else {
return .err(code: "invalid_params", message: "Missing selector", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
}
let selectorLiteral = v2JSONLiteral(selector)
let script = """
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
return { ok: true, selector: \(selectorLiteral), text: String(el.textContent || '').trim() };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok else {
return .err(code: "not_found", message: "Element not found", data: ["selector": selector])
}
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: selector)
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"selector": selector,
"element_ref": ref,
"ref": ref,
"text": v2OrNull(dict["text"])
])
}
}
}
private func v2BrowserFindLast(params: [String: Any]) -> V2CallResult {
guard let selectorRaw = v2BrowserSelector(params) else {
return .err(code: "invalid_params", message: "Missing selector", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
}
let selectorLiteral = v2JSONLiteral(selector)
let script = """
(() => {
const list = document.querySelectorAll(\(selectorLiteral));
if (!list || list.length === 0) return { ok: false, error: 'not_found' };
const idx = list.length - 1;
const el = list[idx];
const finalSelector = `${\(selectorLiteral)}:nth-of-type(${idx + 1})`;
return { ok: true, selector: finalSelector, text: String(el.textContent || '').trim() };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok,
let finalSelector = dict["selector"] as? String,
!finalSelector.isEmpty else {
return .err(code: "not_found", message: "Element not found", data: ["selector": selector])
}
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: finalSelector)
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"selector": finalSelector,
"element_ref": ref,
"ref": ref,
"text": v2OrNull(dict["text"])
])
}
}
}
private func v2BrowserFindNth(params: [String: Any]) -> V2CallResult {
guard let selectorRaw = v2BrowserSelector(params) else {
return .err(code: "invalid_params", message: "Missing selector", data: nil)
}
guard let index = v2Int(params, "index") ?? v2Int(params, "nth") else {
return .err(code: "invalid_params", message: "Missing index", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
}
let selectorLiteral = v2JSONLiteral(selector)
let script = """
(() => {
const list = Array.from(document.querySelectorAll(\(selectorLiteral)));
if (!list.length) return { ok: false, error: 'not_found' };
let idx = \(index);
if (idx < 0) idx = list.length + idx;
if (idx < 0 || idx >= list.length) return { ok: false, error: 'not_found' };
const el = list[idx];
const nth = idx + 1;
const finalSelector = `${\(selectorLiteral)}:nth-of-type(${nth})`;
return { ok: true, selector: finalSelector, index: idx, text: String(el.textContent || '').trim() };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok,
let finalSelector = dict["selector"] as? String,
!finalSelector.isEmpty else {
return .err(code: "not_found", message: "Element not found", data: ["selector": selector, "index": index])
}
let ref = v2BrowserAllocateElementRef(surfaceId: surfaceId, selector: finalSelector)
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"selector": finalSelector,
"element_ref": ref,
"ref": ref,
"index": v2OrNull(dict["index"]),
"text": v2OrNull(dict["text"])
])
}
}
}
private func v2BrowserFrameSelect(params: [String: Any]) -> V2CallResult {
guard let selectorRaw = v2BrowserSelector(params) else {
return .err(code: "invalid_params", message: "Missing selector", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceId) else {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
}
let selectorLiteral = v2JSONLiteral(selector)
let script = """
(() => {
const frame = document.querySelector(\(selectorLiteral));
if (!frame) return { ok: false, error: 'not_found' };
if (!('contentDocument' in frame)) return { ok: false, error: 'not_frame' };
try {
const sameOrigin = !!frame.contentDocument;
if (!sameOrigin) return { ok: false, error: 'cross_origin' };
} catch (_) {
return { ok: false, error: 'cross_origin' };
}
return { ok: true };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
if let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok {
v2BrowserFrameSelectorBySurface[surfaceId] = selector
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"frame_selector": selector
])
}
if let dict = value as? [String: Any],
let errorText = dict["error"] as? String,
errorText == "cross_origin" {
return .err(code: "not_supported", message: "Cross-origin iframe control is not supported", data: ["selector": selector])
}
return .err(code: "not_found", message: "Frame not found", data: ["selector": selector])
}
}
}
private func v2BrowserFrameMain(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in
v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId)
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"frame_selector": NSNull()
])
}
}
private func v2BrowserEnsureTelemetryHooks(surfaceId _: UUID, browserPanel: BrowserPanel) {
_ = v2RunJavaScript(
browserPanel.webView,
script: BrowserPanel.telemetryHookBootstrapScriptSource,
timeout: 5.0
)
}
private func v2BrowserEnsureDialogHooks(browserPanel: BrowserPanel) {
_ = v2RunJavaScript(
browserPanel.webView,
script: BrowserPanel.dialogTelemetryHookBootstrapScriptSource,
timeout: 5.0
)
}
private func v2BrowserDialogRespond(params: [String: Any], accept: Bool) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel)
v2BrowserEnsureDialogHooks(browserPanel: browserPanel)
let text = v2String(params, "text") ?? v2String(params, "prompt_text")
let acceptLiteral = accept ? "true" : "false"
let textLiteral = text.map(v2JSONLiteral) ?? "null"
let script = """
(() => {
const q = window.__cmuxDialogQueue || [];
if (!q.length) return { ok: false, error: 'not_found' };
const entry = q.shift();
if (entry.type === 'confirm') {
window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null };
window.__cmuxDialogDefaults.confirm = \(acceptLiteral);
}
if (entry.type === 'prompt') {
window.__cmuxDialogDefaults = window.__cmuxDialogDefaults || { confirm: false, prompt: null };
if (\(acceptLiteral)) {
window.__cmuxDialogDefaults.prompt = \(textLiteral);
} else {
window.__cmuxDialogDefaults.prompt = null;
}
}
return { ok: true, dialog: entry, remaining: q.length };
})()
"""
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok else {
let pending = v2BrowserPendingDialogs(surfaceId: surfaceId)
return .err(code: "not_found", message: "No pending dialog", data: ["pending": pending])
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"accepted": accept,
"dialog": v2NormalizeJSValue(dict["dialog"]),
"remaining": v2OrNull(dict["remaining"])
])
}
}
}
private func v2BrowserDownloadWait(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, _ in
let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? v2Int(params, "timeout") ?? 10_000)
let timeout = Double(timeoutMs) / 1000.0
let path = v2String(params, "path")
if let path {
let deadline = Date().addingTimeInterval(timeout)
let fm = FileManager.default
while Date() < deadline {
if fm.fileExists(atPath: path),
let attrs = try? fm.attributesOfItem(atPath: path),
let size = attrs[.size] as? NSNumber,
size.intValue > 0 {
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"path": path,
"downloaded": true
])
}
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
}
return .err(code: "timeout", message: "Timed out waiting for download file", data: ["path": path, "timeout_ms": timeoutMs])
}
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let entries = v2BrowserDownloadEventsBySurface[surfaceId] ?? []
if let first = entries.first {
var remaining = entries
remaining.removeFirst()
v2BrowserDownloadEventsBySurface[surfaceId] = remaining
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"download": first
])
}
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.05))
}
return .err(code: "timeout", message: "No download event observed", data: ["timeout_ms": timeoutMs])
}
}
private func v2BrowserCookieDict(_ cookie: HTTPCookie) -> [String: Any] {
var out: [String: Any] = [
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"secure": cookie.isSecure,
"session_only": cookie.isSessionOnly
]
if let expiresDate = cookie.expiresDate {
out["expires"] = Int(expiresDate.timeIntervalSince1970)
} else {
out["expires"] = NSNull()
}
return out
}
private func v2BrowserCookieStoreAll(_ store: WKHTTPCookieStore, timeout: TimeInterval = 3.0) -> [HTTPCookie]? {
var done = false
var cookies: [HTTPCookie] = []
store.getAllCookies { items in
cookies = items
done = true
}
let deadline = Date().addingTimeInterval(timeout)
while !done && Date() < deadline {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
return done ? cookies : nil
}
private func v2BrowserCookieStoreSet(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool {
var done = false
store.setCookie(cookie) {
done = true
}
let deadline = Date().addingTimeInterval(timeout)
while !done && Date() < deadline {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
return done
}
private func v2BrowserCookieStoreDelete(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool {
var done = false
store.delete(cookie) {
done = true
}
let deadline = Date().addingTimeInterval(timeout)
while !done && Date() < deadline {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
return done
}
private func v2BrowserCookieFromObject(_ raw: [String: Any], fallbackURL: URL?) -> HTTPCookie? {
var props: [HTTPCookiePropertyKey: Any] = [:]
if let name = raw["name"] as? String {
props[.name] = name
}
if let value = raw["value"] as? String {
props[.value] = value
}
if let urlStr = raw["url"] as? String, let url = URL(string: urlStr) {
props[.originURL] = url
} else if let fallbackURL {
props[.originURL] = fallbackURL
}
if let domain = raw["domain"] as? String {
props[.domain] = domain
} else if let host = fallbackURL?.host {
props[.domain] = host
}
if let path = raw["path"] as? String {
props[.path] = path
} else {
props[.path] = "/"
}
if let secure = raw["secure"] as? Bool, secure {
props[.secure] = "TRUE"
}
if let expires = raw["expires"] as? TimeInterval {
props[.expires] = Date(timeIntervalSince1970: expires)
} else if let expiresInt = raw["expires"] as? Int {
props[.expires] = Date(timeIntervalSince1970: TimeInterval(expiresInt))
}
return HTTPCookie(properties: props)
}
private func v2BrowserCookiesGet(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
guard var cookies = v2BrowserCookieStoreAll(store) else {
return .err(code: "timeout", message: "Timed out reading cookies", data: nil)
}
if let name = v2String(params, "name") {
cookies = cookies.filter { $0.name == name }
}
if let domain = v2String(params, "domain") {
cookies = cookies.filter { $0.domain.contains(domain) }
}
if let path = v2String(params, "path") {
cookies = cookies.filter { $0.path == path }
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"cookies": cookies.map(v2BrowserCookieDict)
])
}
}
private func v2BrowserCookiesSet(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
let fallbackURL = browserPanel.currentURL
var cookieObjects: [[String: Any]] = []
if let rows = params["cookies"] as? [[String: Any]] {
cookieObjects = rows
} else {
var single: [String: Any] = [:]
if let name = v2String(params, "name") { single["name"] = name }
if let value = v2String(params, "value") { single["value"] = value }
if let url = v2String(params, "url") { single["url"] = url }
if let domain = v2String(params, "domain") { single["domain"] = domain }
if let path = v2String(params, "path") { single["path"] = path }
if let secure = v2Bool(params, "secure") { single["secure"] = secure }
if let expires = v2Int(params, "expires") { single["expires"] = expires }
if !single.isEmpty {
cookieObjects = [single]
}
}
guard !cookieObjects.isEmpty else {
return .err(code: "invalid_params", message: "Missing cookies payload", data: nil)
}
var setCount = 0
for raw in cookieObjects {
guard let cookie = v2BrowserCookieFromObject(raw, fallbackURL: fallbackURL) else {
return .err(code: "invalid_params", message: "Invalid cookie payload", data: ["cookie": raw])
}
if v2BrowserCookieStoreSet(store, cookie: cookie) {
setCount += 1
} else {
return .err(code: "timeout", message: "Timed out setting cookie", data: ["name": cookie.name])
}
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"set": setCount
])
}
}
private func v2BrowserCookiesClear(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
guard let cookies = v2BrowserCookieStoreAll(store) else {
return .err(code: "timeout", message: "Timed out reading cookies", data: nil)
}
let name = v2String(params, "name")
let domain = v2String(params, "domain")
let clearAll = params["all"] == nil && name == nil && domain == nil
let targets = cookies.filter { cookie in
if clearAll { return true }
if let name, cookie.name != name { return false }
if let domain, !cookie.domain.contains(domain) { return false }
return true
}
var removed = 0
for cookie in targets {
if v2BrowserCookieStoreDelete(store, cookie: cookie) {
removed += 1
}
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"cleared": removed
])
}
}
private func v2BrowserStorageType(_ params: [String: Any]) -> String {
let type = (v2String(params, "storage") ?? v2String(params, "type") ?? "local").lowercased()
return (type == "session") ? "session" : "local"
}
private func v2BrowserStorageGet(params: [String: Any]) -> V2CallResult {
let storageType = v2BrowserStorageType(params)
let key = v2String(params, "key")
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let typeLiteral = v2JSONLiteral(storageType)
let keyLiteral = key.map(v2JSONLiteral) ?? "null"
let script = """
(() => {
const type = String(\(typeLiteral));
const key = \(keyLiteral);
const st = type === 'session' ? window.sessionStorage : window.localStorage;
if (!st) return { ok: false, error: 'not_available' };
if (key == null) {
const out = {};
for (let i = 0; i < st.length; i++) {
const k = st.key(i);
out[k] = st.getItem(k);
}
return { ok: true, value: out };
}
return { ok: true, value: st.getItem(String(key)) };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok else {
return .err(code: "invalid_state", message: "Storage unavailable", data: ["type": storageType])
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"type": storageType,
"key": v2OrNull(key),
"value": v2NormalizeJSValue(dict["value"])
])
}
}
}
private func v2BrowserStorageSet(params: [String: Any]) -> V2CallResult {
let storageType = v2BrowserStorageType(params)
guard let key = v2String(params, "key") else {
return .err(code: "invalid_params", message: "Missing key", data: nil)
}
guard let value = params["value"] else {
return .err(code: "invalid_params", message: "Missing value", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let typeLiteral = v2JSONLiteral(storageType)
let keyLiteral = v2JSONLiteral(key)
let valueLiteral = v2JSONLiteral(v2NormalizeJSValue(value))
let script = """
(() => {
const type = String(\(typeLiteral));
const key = String(\(keyLiteral));
const value = \(valueLiteral);
const st = type === 'session' ? window.sessionStorage : window.localStorage;
if (!st) return { ok: false, error: 'not_available' };
st.setItem(key, value == null ? '' : String(value));
return { ok: true };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok else {
return .err(code: "invalid_state", message: "Storage unavailable", data: ["type": storageType])
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"type": storageType,
"key": key
])
}
}
}
private func v2BrowserStorageClear(params: [String: Any]) -> V2CallResult {
let storageType = v2BrowserStorageType(params)
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let typeLiteral = v2JSONLiteral(storageType)
let script = """
(() => {
const type = String(\(typeLiteral));
const st = type === 'session' ? window.sessionStorage : window.localStorage;
if (!st) return { ok: false, error: 'not_available' };
st.clear();
return { ok: true };
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
guard let dict = value as? [String: Any],
let ok = dict["ok"] as? Bool,
ok else {
return .err(code: "invalid_state", message: "Storage unavailable", data: ["type": storageType])
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"type": storageType,
"cleared": true
])
}
}
}
private func v2BrowserTabList(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var payload: [String: Any]?
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return }
let browserPanels = orderedPanels(in: ws).compactMap { panel -> BrowserPanel? in
panel as? BrowserPanel
}
let tabs: [[String: Any]] = browserPanels.enumerated().map { index, panel in
[
"id": panel.id.uuidString,
"ref": v2Ref(kind: .surface, uuid: panel.id),
"index": index,
"title": ws.panelTitle(panelId: panel.id) ?? panel.displayTitle,
"url": panel.currentURL?.absoluteString ?? "",
"focused": panel.id == ws.focusedPanelId,
"pane_id": v2OrNull(ws.paneId(forPanelId: panel.id)?.id.uuidString),
"pane_ref": v2Ref(kind: .pane, uuid: ws.paneId(forPanelId: panel.id)?.id)
]
}
payload = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": v2OrNull(ws.focusedPanelId?.uuidString),
"surface_ref": v2Ref(kind: .surface, uuid: ws.focusedPanelId),
"tabs": tabs
]
}
guard let payload else {
return .err(code: "not_found", message: "Workspace not found", data: nil)
}
return .ok(payload)
}
private func v2BrowserTabNew(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let url = v2String(params, "url").flatMap(URL.init(string:))
var result: V2CallResult = .err(code: "internal_error", message: "Failed to create browser tab", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let paneUUID = v2UUID(params, "pane_id")
?? v2UUID(params, "target_pane_id")
?? (v2UUID(params, "surface_id").flatMap { ws.paneId(forPanelId: $0)?.id })
?? ws.paneId(forPanelId: ws.focusedPanelId ?? UUID())?.id
?? ws.bonsplitController.focusedPaneId?.id
guard let paneUUID,
let pane = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) else {
result = .err(code: "not_found", message: "Target pane not found", data: nil)
return
}
guard let panel = ws.newBrowserSurface(inPane: pane, url: url, focus: v2FocusAllowed()) else {
result = .err(code: "internal_error", message: "Failed to create browser tab", data: nil)
return
}
result = .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": pane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: pane.id),
"surface_id": panel.id.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: panel.id),
"url": panel.currentURL?.absoluteString ?? ""
])
}
return result
}
private func v2BrowserTabSwitch(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Browser tab not found", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let browserIds = orderedPanels(in: ws).compactMap { panel -> UUID? in
(panel as? BrowserPanel)?.id
}
let targetId: UUID? = {
if let explicit = v2UUID(params, "target_surface_id") ?? v2UUID(params, "tab_id") {
return explicit
}
if let idx = v2Int(params, "index"), idx >= 0, idx < browserIds.count {
return browserIds[idx]
}
return v2UUID(params, "surface_id")
}()
guard let targetId, browserIds.contains(targetId) else {
result = .err(code: "not_found", message: "Browser tab not found", data: nil)
return
}
ws.focusPanel(targetId)
result = .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": targetId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: targetId)
])
}
return result
}
private func v2BrowserTabClose(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "Browser tab not found", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let browserIds = orderedPanels(in: ws).compactMap { panel -> UUID? in
(panel as? BrowserPanel)?.id
}
guard !browserIds.isEmpty else {
result = .err(code: "not_found", message: "No browser tabs", data: nil)
return
}
let targetId: UUID? = {
if let explicit = v2UUID(params, "target_surface_id") ?? v2UUID(params, "tab_id") {
return explicit
}
if let idx = v2Int(params, "index"), idx >= 0, idx < browserIds.count {
return browserIds[idx]
}
if let sid = v2UUID(params, "surface_id") {
return sid
}
return ws.focusedPanelId
}()
guard let targetId, browserIds.contains(targetId) else {
result = .err(code: "not_found", message: "Browser tab not found", data: nil)
return
}
if ws.panels.count <= 1 {
result = .err(code: "invalid_state", message: "Cannot close the last surface", data: nil)
return
}
let ok = ws.closePanel(targetId, force: true)
result = ok
? .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": targetId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: targetId)
])
: .err(code: "internal_error", message: "Failed to close browser tab", data: ["surface_id": targetId.uuidString])
}
return result
}
private func v2BrowserConsoleList(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel)
let clear = v2Bool(params, "clear") ?? false
let clearLiteral = clear ? "true" : "false"
let script = """
(() => {
const items = Array.isArray(window.__cmuxConsoleLog) ? window.__cmuxConsoleLog.slice() : [];
if (\(clearLiteral)) {
window.__cmuxConsoleLog = [];
}
return { ok: true, items };
})()
"""
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
let dict = value as? [String: Any]
let items = (dict?["items"] as? [Any]) ?? []
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"entries": items.map(v2NormalizeJSValue),
"count": items.count
])
}
}
}
private func v2BrowserConsoleClear(params: [String: Any]) -> V2CallResult {
var withClear = params
withClear["clear"] = true
return v2BrowserConsoleList(params: withClear)
}
private func v2BrowserErrorsList(params: [String: Any]) -> V2CallResult {
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
v2BrowserEnsureTelemetryHooks(surfaceId: surfaceId, browserPanel: browserPanel)
let clear = v2Bool(params, "clear") ?? false
let clearLiteral = clear ? "true" : "false"
let script = """
(() => {
const items = Array.isArray(window.__cmuxErrorLog) ? window.__cmuxErrorLog.slice() : [];
if (\(clearLiteral)) {
window.__cmuxErrorLog = [];
}
return { ok: true, items };
})()
"""
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
let dict = value as? [String: Any]
let items = (dict?["items"] as? [Any]) ?? []
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"errors": items.map(v2NormalizeJSValue),
"count": items.count
])
}
}
}
private func v2BrowserHighlight(params: [String: Any]) -> V2CallResult {
return v2BrowserSelectorAction(params: params, actionName: "highlight") { selectorLiteral in
"""
(() => {
const el = document.querySelector(\(selectorLiteral));
if (!el) return { ok: false, error: 'not_found' };
const prev = el.style.outline;
const prevOffset = el.style.outlineOffset;
el.style.outline = '3px solid #ff9f0a';
el.style.outlineOffset = '2px';
setTimeout(() => {
el.style.outline = prev;
el.style.outlineOffset = prevOffset;
}, 1200);
return { ok: true };
})()
"""
}
}
private func v2BrowserStateSave(params: [String: Any]) -> V2CallResult {
guard let path = v2String(params, "path") else {
return .err(code: "invalid_params", message: "Missing path", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let storageScript = """
(() => {
const readStorage = (st) => {
const out = {};
if (!st) return out;
for (let i = 0; i < st.length; i++) {
const k = st.key(i);
out[k] = st.getItem(k);
}
return out;
};
return {
local: readStorage(window.localStorage),
session: readStorage(window.sessionStorage)
};
})()
"""
let storageValue: Any
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: storageScript, timeout: 10.0) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
storageValue = v2NormalizeJSValue(value)
}
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
let cookies = (v2BrowserCookieStoreAll(store) ?? []).map(v2BrowserCookieDict)
let state: [String: Any] = [
"url": browserPanel.currentURL?.absoluteString ?? "",
"cookies": cookies,
"storage": storageValue,
"frame_selector": v2OrNull(v2BrowserFrameSelectorBySurface[surfaceId])
]
do {
let data = try JSONSerialization.data(withJSONObject: state, options: [.prettyPrinted, .sortedKeys])
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
} catch {
return .err(code: "internal_error", message: "Failed to write state file", data: ["path": path, "error": error.localizedDescription])
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"path": path,
"cookies": cookies.count
])
}
}
private func v2BrowserStateLoad(params: [String: Any]) -> V2CallResult {
guard let path = v2String(params, "path") else {
return .err(code: "invalid_params", message: "Missing path", data: nil)
}
let url = URL(fileURLWithPath: path)
let raw: [String: Any]
do {
let data = try Data(contentsOf: url)
guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return .err(code: "invalid_params", message: "State file must contain a JSON object", data: ["path": path])
}
raw = obj
} catch {
return .err(code: "not_found", message: "Failed to read state file", data: ["path": path, "error": error.localizedDescription])
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
if let frameSelector = raw["frame_selector"] as? String, !frameSelector.isEmpty {
v2BrowserFrameSelectorBySurface[surfaceId] = frameSelector
} else {
v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId)
}
if let urlStr = raw["url"] as? String,
!urlStr.isEmpty,
let parsed = URL(string: urlStr) {
browserPanel.navigate(to: parsed)
}
if let cookieRows = raw["cookies"] as? [[String: Any]] {
let store = browserPanel.webView.configuration.websiteDataStore.httpCookieStore
for row in cookieRows {
if let cookie = v2BrowserCookieFromObject(row, fallbackURL: browserPanel.currentURL) {
_ = v2BrowserCookieStoreSet(store, cookie: cookie)
}
}
}
if let storage = raw["storage"] as? [String: Any] {
let storageLiteral = v2JSONLiteral(storage)
let script = """
(() => {
const payload = \(storageLiteral);
const apply = (st, data) => {
if (!st || !data || typeof data !== 'object') return;
st.clear();
for (const [k, v] of Object.entries(data)) {
st.setItem(String(k), v == null ? '' : String(v));
}
};
apply(window.localStorage, payload.local);
apply(window.sessionStorage, payload.session);
return true;
})()
"""
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0)
}
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"path": path,
"loaded": true
])
}
}
private func v2BrowserAddInitScript(params: [String: Any]) -> V2CallResult {
guard let script = v2String(params, "script") ?? v2String(params, "content") else {
return .err(code: "invalid_params", message: "Missing script", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
var scripts = v2BrowserInitScriptsBySurface[surfaceId] ?? []
scripts.append(script)
v2BrowserInitScriptsBySurface[surfaceId] = scripts
let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false)
browserPanel.webView.configuration.userContentController.addUserScript(userScript)
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0)
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"scripts": scripts.count
])
}
}
private func v2BrowserAddScript(params: [String: Any]) -> V2CallResult {
guard let script = v2String(params, "script") ?? v2String(params, "content") else {
return .err(code: "invalid_params", message: "Missing script", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"value": v2NormalizeJSValue(value)
])
}
}
}
private func v2BrowserAddStyle(params: [String: Any]) -> V2CallResult {
guard let css = v2String(params, "css") ?? v2String(params, "style") ?? v2String(params, "content") else {
return .err(code: "invalid_params", message: "Missing css/style content", data: nil)
}
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
var styles = v2BrowserInitStylesBySurface[surfaceId] ?? []
styles.append(css)
v2BrowserInitStylesBySurface[surfaceId] = styles
let cssLiteral = v2JSONLiteral(css)
let source = """
(() => {
const el = document.createElement('style');
el.textContent = String(\(cssLiteral));
(document.head || document.documentElement || document.body).appendChild(el);
return true;
})()
"""
let userScript = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false)
browserPanel.webView.configuration.userContentController.addUserScript(userScript)
_ = v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: source, timeout: 10.0)
return .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"styles": styles.count
])
}
}
private func v2BrowserViewportSet(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.viewport.set", details: "WKWebView does not provide a per-tab programmable viewport emulation API equivalent to CDP")
}
private func v2BrowserGeolocationSet(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.geolocation.set", details: "WKWebView does not expose per-tab geolocation spoofing hooks equivalent to Playwright/CDP")
}
private func v2BrowserOfflineSet(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.offline.set", details: "WKWebView does not expose reliable per-tab offline emulation")
}
private func v2BrowserTraceStart(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.trace.start", details: "Playwright trace artifacts are not available on WKWebView")
}
private func v2BrowserTraceStop(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.trace.stop", details: "Playwright trace artifacts are not available on WKWebView")
}
private func v2BrowserNetworkRoute(params: [String: Any]) -> V2CallResult {
if let surfaceId = v2UUID(params, "surface_id") {
v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "route", "params": params])
}
return v2BrowserNotSupported("browser.network.route", details: "WKWebView does not provide CDP-style request interception/mocking")
}
private func v2BrowserNetworkUnroute(params: [String: Any]) -> V2CallResult {
if let surfaceId = v2UUID(params, "surface_id") {
v2BrowserRecordUnsupportedRequest(surfaceId: surfaceId, request: ["action": "unroute", "params": params])
}
return v2BrowserNotSupported("browser.network.unroute", details: "WKWebView does not provide CDP-style request interception/mocking")
}
private func v2BrowserNetworkRequests(params: [String: Any]) -> V2CallResult {
if let surfaceId = v2UUID(params, "surface_id") {
let items = v2BrowserUnsupportedNetworkRequestsBySurface[surfaceId] ?? []
return .err(code: "not_supported", message: "browser.network.requests is not supported on WKWebView", data: [
"details": "Request interception logs are unavailable without CDP network hooks",
"recorded_requests": items
])
}
return v2BrowserNotSupported("browser.network.requests", details: "Request interception logs are unavailable without CDP network hooks")
}
private func v2BrowserScreencastStart(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.screencast.start", details: "WKWebView does not expose CDP screencast streaming")
}
private func v2BrowserScreencastStop(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.screencast.stop", details: "WKWebView does not expose CDP screencast streaming")
}
private func v2BrowserInputMouse(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.input_mouse", details: "Raw CDP mouse injection is unavailable; use browser.click/hover/scroll")
}
private func v2BrowserInputKeyboard(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.input_keyboard", details: "Raw CDP keyboard injection is unavailable; use browser.press/keydown/keyup")
}
private func v2BrowserInputTouch(params _: [String: Any]) -> V2CallResult {
v2BrowserNotSupported("browser.input_touch", details: "Raw CDP touch injection is unavailable on WKWebView")
}
#if DEBUG
// MARK: - V2 Debug / Test-only Methods
private func v2DebugShortcutSet(params: [String: Any]) -> V2CallResult {
guard let name = v2String(params, "name"),
let combo = v2String(params, "combo") else {
return .err(code: "invalid_params", message: "Missing name/combo", data: nil)
}
let resp = setShortcut("\(name) \(combo)")
return resp == "OK"
? .ok([:])
: .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugShortcutSimulate(params: [String: Any]) -> V2CallResult {
guard let combo = v2String(params, "combo") else {
return .err(code: "invalid_params", message: "Missing combo", data: nil)
}
let resp = simulateShortcut(combo)
return resp == "OK"
? .ok([:])
: .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugType(params: [String: Any]) -> V2CallResult {
guard let text = params["text"] as? String else {
return .err(code: "invalid_params", message: "Missing text", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "No window", data: nil)
DispatchQueue.main.sync {
guard let window = NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else {
result = .err(code: "not_found", message: "No window", data: nil)
return
}
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
guard let fr = window.firstResponder else {
result = .err(code: "not_found", message: "No first responder", data: nil)
return
}
if let client = fr as? NSTextInputClient {
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
result = .ok([:])
return
}
(fr as? NSResponder)?.insertText(text)
result = .ok([:])
}
return result
}
private func v2DebugActivateApp() -> V2CallResult {
let resp = activateApp()
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugToggleCommandPalette(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: ["window_id": requestedWindowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: requestedWindowId)]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteToggleRequested, object: targetWindow)
}
return result
}
private func v2DebugOpenCommandPaletteRenameTabInput(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameTabRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteVisible(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visible = false
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible
])
}
private func v2DebugCommandPaletteSelection(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visible = false
var selectedIndex = 0
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible,
"selected_index": max(0, selectedIndex)
])
}
private func v2DebugCommandPaletteResults(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
let requestedLimit = params["limit"] as? Int
let limit = max(1, min(100, requestedLimit ?? 20))
var visible = false
var selectedIndex = 0
var snapshot = CommandPaletteDebugSnapshot.empty
DispatchQueue.main.sync {
visible = AppDelegate.shared?.isCommandPaletteVisible(windowId: windowId) ?? false
selectedIndex = AppDelegate.shared?.commandPaletteSelectionIndex(windowId: windowId) ?? 0
snapshot = AppDelegate.shared?.commandPaletteSnapshot(windowId: windowId) ?? .empty
}
let rows = Array(snapshot.results.prefix(limit)).map { row in
[
"command_id": row.commandId,
"title": row.title,
"shortcut_hint": v2OrNull(row.shortcutHint),
"trailing_label": v2OrNull(row.trailingLabel),
"score": row.score
] as [String: Any]
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible,
"selected_index": max(0, selectedIndex),
"query": snapshot.query,
"mode": snapshot.mode,
"results": rows
])
}
private func v2DebugCommandPaletteRenameInputInteraction(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameInputInteractionRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteRenameInputDeleteBackward(params: [String: Any]) -> V2CallResult {
let requestedWindowId = v2UUID(params, "window_id")
var result: V2CallResult = .ok([:])
DispatchQueue.main.sync {
let targetWindow: NSWindow?
if let requestedWindowId {
guard let window = AppDelegate.shared?.mainWindow(for: requestedWindowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: [
"window_id": requestedWindowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: requestedWindowId)
]
)
return
}
targetWindow = window
} else {
targetWindow = NSApp.keyWindow ?? NSApp.mainWindow
}
NotificationCenter.default.post(name: .commandPaletteRenameInputDeleteBackwardRequested, object: targetWindow)
}
return result
}
private func v2DebugCommandPaletteRenameInputSelection(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var result: V2CallResult = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"focused": false,
"selection_location": 0,
"selection_length": 0,
"text_length": 0
])
DispatchQueue.main.sync {
guard let window = AppDelegate.shared?.mainWindow(for: windowId) else {
result = .err(
code: "not_found",
message: "Window not found",
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
)
return
}
guard let editor = window.firstResponder as? NSTextView, editor.isFieldEditor else {
return
}
let selectedRange = editor.selectedRange()
let textLength = (editor.string as NSString).length
result = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"focused": true,
"selection_location": max(0, selectedRange.location),
"selection_length": max(0, selectedRange.length),
"text_length": max(0, textLength)
])
}
return result
}
private func v2DebugCommandPaletteRenameInputSelectAll(params: [String: Any]) -> V2CallResult {
if let rawEnabled = params["enabled"] {
guard let enabled = rawEnabled as? Bool else {
return .err(
code: "invalid_params",
message: "enabled must be a bool",
data: ["enabled": rawEnabled]
)
}
DispatchQueue.main.sync {
UserDefaults.standard.set(
enabled,
forKey: CommandPaletteRenameSelectionSettings.selectAllOnFocusKey
)
}
}
var enabled = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
DispatchQueue.main.sync {
enabled = CommandPaletteRenameSelectionSettings.selectAllOnFocusEnabled()
}
return .ok([
"enabled": enabled
])
}
private func v2DebugBrowserAddressBarFocused(params: [String: Any]) -> V2CallResult {
let requestedSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "panel_id")
var focusedSurfaceId: UUID?
DispatchQueue.main.sync {
focusedSurfaceId = AppDelegate.shared?.focusedBrowserAddressBarPanelId()
}
var payload: [String: Any] = [
"focused_surface_id": v2OrNull(focusedSurfaceId?.uuidString),
"focused_surface_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
"focused_panel_id": v2OrNull(focusedSurfaceId?.uuidString),
"focused_panel_ref": v2Ref(kind: .surface, uuid: focusedSurfaceId),
"focused": focusedSurfaceId != nil
]
if let requestedSurfaceId {
payload["surface_id"] = requestedSurfaceId.uuidString
payload["surface_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
payload["panel_id"] = requestedSurfaceId.uuidString
payload["panel_ref"] = v2Ref(kind: .surface, uuid: requestedSurfaceId)
payload["focused"] = (focusedSurfaceId == requestedSurfaceId)
}
return .ok(payload)
}
private func v2DebugSidebarVisible(params: [String: Any]) -> V2CallResult {
guard let windowId = v2UUID(params, "window_id") else {
return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)
}
var visibility: Bool?
DispatchQueue.main.sync {
visibility = AppDelegate.shared?.sidebarVisibility(windowId: windowId)
}
guard let visible = visibility else {
return .err(
code: "not_found",
message: "Window not found",
data: ["window_id": windowId.uuidString, "window_ref": v2Ref(kind: .window, uuid: windowId)]
)
}
return .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"visible": visible
])
}
private func v2DebugIsTerminalFocused(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2String(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
}
let resp = isTerminalFocused(surfaceId)
if resp.hasPrefix("ERROR") {
return .err(code: "internal_error", message: resp, data: nil)
}
return .ok(["focused": resp.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "true"])
}
private func v2DebugReadTerminalText(params: [String: Any]) -> V2CallResult {
let surfaceArg = v2String(params, "surface_id") ?? ""
let resp = readTerminalText(surfaceArg)
guard resp.hasPrefix("OK ") else {
return .err(code: "internal_error", message: resp, data: nil)
}
let b64 = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
return .ok(["base64": b64])
}
private func v2DebugRenderStats(params: [String: Any]) -> V2CallResult {
let surfaceArg = v2String(params, "surface_id") ?? ""
let resp = renderStats(surfaceArg)
guard resp.hasPrefix("OK ") else {
return .err(code: "internal_error", message: resp, data: nil)
}
let jsonStr = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = jsonStr.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: []) else {
return .err(code: "internal_error", message: "render_stats JSON decode failed", data: ["payload": String(jsonStr.prefix(200))])
}
return .ok(["stats": obj])
}
private func v2DebugLayout() -> V2CallResult {
let resp = layoutDebug()
guard resp.hasPrefix("OK ") else {
return .err(code: "internal_error", message: resp, data: nil)
}
let jsonStr = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = jsonStr.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data, options: []) else {
return .err(code: "internal_error", message: "layout_debug JSON decode failed", data: ["payload": String(jsonStr.prefix(200))])
}
return .ok(["layout": obj])
}
private func v2DebugPortalStats() -> V2CallResult {
let payload: [String: Any] = v2MainSync {
TerminalWindowPortalRegistry.debugPortalStats()
}
return .ok(payload)
}
private func v2DebugBonsplitUnderflowCount() -> V2CallResult {
let resp = bonsplitUnderflowCount()
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
let n = Int(resp.split(separator: " ").last ?? "0") ?? 0
return .ok(["count": n])
}
private func v2DebugResetBonsplitUnderflowCount() -> V2CallResult {
let resp = resetBonsplitUnderflowCount()
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugEmptyPanelCount() -> V2CallResult {
let resp = emptyPanelCount()
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
let n = Int(resp.split(separator: " ").last ?? "0") ?? 0
return .ok(["count": n])
}
private func v2DebugResetEmptyPanelCount() -> V2CallResult {
let resp = resetEmptyPanelCount()
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugFocusNotification(params: [String: Any]) -> V2CallResult {
guard let wsId = v2String(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing workspace_id", data: nil)
}
let surfaceId = v2String(params, "surface_id")
let args = surfaceId != nil ? "\(wsId) \(surfaceId!)" : wsId
let resp = focusFromNotification(args)
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugFlashCount(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2String(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
}
let resp = flashCount(surfaceId)
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
let n = Int(resp.split(separator: " ").last ?? "0") ?? 0
return .ok(["count": n])
}
private func v2DebugResetFlashCounts() -> V2CallResult {
let resp = resetFlashCounts()
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugPanelSnapshot(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2String(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
}
let label = v2String(params, "label") ?? ""
let args = label.isEmpty ? surfaceId : "\(surfaceId) \(label)"
let resp = panelSnapshot(args)
guard resp.hasPrefix("OK ") else { return .err(code: "internal_error", message: resp, data: nil) }
let payload = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
let parts = payload.split(separator: " ", maxSplits: 4).map(String.init)
guard parts.count == 5 else {
return .err(code: "internal_error", message: "panel_snapshot parse failed", data: ["payload": payload])
}
return .ok([
"surface_id": parts[0],
"changed_pixels": Int(parts[1]) ?? -1,
"width": Int(parts[2]) ?? 0,
"height": Int(parts[3]) ?? 0,
"path": parts[4]
])
}
private func v2DebugPanelSnapshotReset(params: [String: Any]) -> V2CallResult {
guard let surfaceId = v2String(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing surface_id", data: nil)
}
let resp = panelSnapshotReset(surfaceId)
return resp == "OK" ? .ok([:]) : .err(code: "internal_error", message: resp, data: nil)
}
private func v2DebugScreenshot(params: [String: Any]) -> V2CallResult {
let label = v2String(params, "label") ?? ""
let resp = captureScreenshot(label)
guard resp.hasPrefix("OK ") else {
return .err(code: "internal_error", message: resp, data: nil)
}
let payload = String(resp.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
let parts = payload.split(separator: " ", maxSplits: 1).map(String.init)
guard parts.count == 2 else {
return .err(code: "internal_error", message: "screenshot parse failed", data: ["payload": payload])
}
return .ok([
"screenshot_id": parts[0],
"path": parts[1]
])
}
#endif
private struct ReadScreenOptions {
let surfaceArg: String
let includeScrollback: Bool
let lineLimit: Int?
}
private struct ReadScreenParseError: Error {
let message: String
}
private func parseReadScreenArgs(_ args: String) -> Result<ReadScreenOptions, ReadScreenParseError> {
let tokens = args
.split(whereSeparator: { $0.isWhitespace })
.map(String.init)
var surfaceArg: String?
var includeScrollback = false
var lineLimit: Int?
var idx = 0
while idx < tokens.count {
let token = tokens[idx]
switch token {
case "--scrollback":
includeScrollback = true
idx += 1
case "--lines":
guard idx + 1 < tokens.count, let parsed = Int(tokens[idx + 1]), parsed > 0 else {
return .failure(ReadScreenParseError(message: "ERROR: --lines must be greater than 0"))
}
lineLimit = parsed
includeScrollback = true
idx += 2
default:
guard surfaceArg == nil else {
return .failure(ReadScreenParseError(message: "ERROR: Usage: read_screen [id|idx] [--scrollback] [--lines <n>]"))
}
surfaceArg = token
idx += 1
}
}
return .success(
ReadScreenOptions(
surfaceArg: surfaceArg ?? "",
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
)
}
private func tailTerminalLines(_ text: String, maxLines: Int) -> String {
guard maxLines > 0 else { return "" }
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
guard lines.count > maxLines else { return text }
return lines.suffix(maxLines).joined(separator: "\n")
}
private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines)
var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
let panelId: UUID?
if trimmedSurfaceArg.isEmpty {
panelId = tab.focusedPanelId
} else {
panelId = resolveSurfaceId(from: trimmedSurfaceArg, tab: tab)
}
guard let panelId,
let terminalPanel = tab.terminalPanel(for: panelId) else {
result = "ERROR: Terminal surface not found"
return
}
result = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
}
return result
}
private func readScreenText(_ args: String) -> String {
let options: ReadScreenOptions
switch parseReadScreenArgs(args) {
case .success(let parsed):
options = parsed
case .failure(let error):
return error.message
}
let response = readTerminalTextBase64(
surfaceArg: options.surfaceArg,
includeScrollback: options.includeScrollback,
lineLimit: options.lineLimit
)
guard response.hasPrefix("OK ") else { return response }
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
if payload.isEmpty {
return ""
}
guard let data = Data(base64Encoded: payload) else {
return "ERROR: Failed to decode terminal text"
}
return String(decoding: data, as: UTF8.self)
}
private func helpText() -> String {
var text = """
Hierarchy: Workspace (sidebar tab) > Pane (split region) > Surface (nested tab) > Panel (terminal/browser)
Available commands:
ping - Check if server is running
auth <password> - Authenticate this connection (required in password mode)
list_workspaces - List all workspaces with IDs
new_workspace - Create a new workspace
select_workspace <id|index> - Select workspace by ID or index (0-based)
current_workspace - Get current workspace ID
close_workspace <id> - Close workspace by ID
Split & surface commands:
new_split <direction> [panel] - Split panel (left/right/up/down)
drag_surface_to_split <id|idx> <direction> - Move surface into a new split (drag-to-edge)
new_pane [--type=terminal|browser] [--direction=left|right|up|down] [--url=...]
new_surface [--type=terminal|browser] [--pane=<pane-id|index>] [--url=...]
list_surfaces [workspace] - List surfaces for workspace (current if omitted)
list_panes - List all panes with IDs
list_pane_surfaces [--pane=<pane-id|index>] - List surfaces in pane
focus_surface <id|idx> - Focus surface by ID or index
focus_pane <pane-id|index> - Focus a pane
focus_surface_by_panel <panel_id> - Focus surface by panel ID
close_surface [id|idx] - Close surface (collapse split)
refresh_surfaces - Force refresh all terminals
surface_health [workspace] - Check view health of all surfaces
Input commands:
send <text> - Send text to current terminal
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
send_surface <id|idx> <text> - Send text to a specific terminal
send_key_surface <id|idx> <key> - Send special key to a specific terminal
read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text)
Notification commands:
notify <title>|<subtitle>|<body> - Notify focused panel
notify_surface <id|idx> <payload> - Notify a specific surface
notify_target <workspace_id> <surface_id> <payload> - Notify by workspace+surface
list_notifications - List all notifications
clear_notifications [--tab=X] - Clear notifications (all or per-tab)
set_app_focus <active|inactive|clear> - Override app focus state
simulate_app_active - Trigger app active handler
set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set a status entry
report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X] - Set sidebar metadata entry
report_meta_block <key> [--priority=N] [--tab=X] -- <markdown> - Set freeform sidebar markdown block
clear_status <key> [--tab=X] - Remove a status entry
clear_meta <key> [--tab=X] - Remove sidebar metadata entry
clear_meta_block <key> [--tab=X] - Remove sidebar markdown block
list_status [--tab=X] - List all status entries
list_meta [--tab=X] - List sidebar metadata entries
list_meta_blocks [--tab=X] - List sidebar markdown blocks
log [--level=X] [--source=X] [--tab=X] -- <message> - Append a log entry
clear_log [--tab=X] - Clear log entries
list_log [--limit=N] [--tab=X] - List log entries
set_progress <0.0-1.0> [--label=X] [--tab=X] - Set progress bar
clear_progress [--tab=X] - Clear progress bar
report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y] - Report git branch
clear_git_branch [--tab=X] [--panel=Y] - Clear git branch
report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Report pull request / review item
report_review <number> <url> [--label=MR] [--state=open|merged|closed] [--tab=X] [--panel=Y] - Alias for provider-specific review item
clear_pr [--tab=X] [--panel=Y] - Clear pull request
report_ports <port1> [port2...] [--tab=X] [--panel=Y] - Report listening ports
report_tty <tty_name> [--tab=X] [--panel=Y] - Register TTY for batched port scanning
ports_kick [--tab=X] [--panel=Y] - Request batched port scan for panel
report_pwd <path> [--tab=X] [--panel=Y] - Report current working directory
clear_ports [--tab=X] [--panel=Y] - Clear listening ports
sidebar_state [--tab=X] - Dump sidebar metadata
reset_sidebar [--tab=X] - Clear sidebar metadata
Browser commands:
open_browser [url] - Create browser panel with optional URL
navigate <panel_id> <url> - Navigate browser to URL
browser_back <panel_id> - Go back in browser history
browser_forward <panel_id> - Go forward in browser history
browser_reload <panel_id> - Reload browser page
get_url <panel_id> - Get current URL of browser panel
focus_webview <panel_id> - Move keyboard focus into the WKWebView (for tests)
is_webview_focused <panel_id> - Return true/false if WKWebView is first responder
help - Show this help
"""
#if DEBUG
text += """
focus_notification <workspace|idx> [surface|idx] - Focus via notification flow
flash_count <id|idx> - Read flash count for a panel
reset_flash_counts - Reset flash counters
screenshot [label] - Capture window screenshot
set_shortcut <name> <combo|clear> - Set a keyboard shortcut (test-only)
simulate_shortcut <combo> - Simulate a keyDown shortcut (test-only)
simulate_type <text> - Insert text into the current first responder (test-only)
simulate_file_drop <id|idx> <path[|path...]> - Simulate dropping file path(s) on terminal (test-only)
seed_drag_pasteboard_fileurl - Seed NSDrag pasteboard with public.file-url (test-only)
seed_drag_pasteboard_tabtransfer - Seed NSDrag pasteboard with tab transfer type (test-only)
seed_drag_pasteboard_sidebar_reorder - Seed NSDrag pasteboard with sidebar reorder type (test-only)
seed_drag_pasteboard_types <types> - Seed NSDrag pasteboard with comma/space-separated types (fileurl, tabtransfer, sidebarreorder, or raw UTI)
clear_drag_pasteboard - Clear NSDrag pasteboard (test-only)
drop_hit_test <x 0-1> <y 0-1> - Hit-test file-drop overlay at normalised coords (test-only)
drag_hit_chain <x 0-1> <y 0-1> - Return hit-view chain at normalised coords (test-only)
overlay_hit_gate <event|none> - Return true/false if file-drop overlay would capture hit-testing for event type (test-only)
overlay_drop_gate [external|local] - Return true/false if file-drop overlay would capture drag destination routing (test-only)
portal_hit_gate <event|none> - Return true/false if terminal portal should pass hit-testing to SwiftUI drag targets (test-only)
sidebar_overlay_gate [active|inactive] - Return true/false if sidebar outside-drop overlay would capture (test-only)
terminal_drop_overlay_probe [deferred|direct] - Trigger focused terminal drop-overlay show path and report animation counts (test-only)
activate_app - Bring app + main window to front (test-only)
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
render_stats [id|idx] - Read terminal render stats (draw counters, test-only)
layout_debug - Dump bonsplit layout + selected panel bounds (test-only)
bonsplit_underflow_count - Count bonsplit arranged-subview underflow events (test-only)
reset_bonsplit_underflow_count - Reset bonsplit underflow counter (test-only)
empty_panel_count - Count EmptyPanelView appearances (test-only)
reset_empty_panel_count - Reset EmptyPanelView appearance count (test-only)
"""
#endif
return text
}
#if DEBUG
private func debugShortcutName(for action: KeyboardShortcutSettings.Action) -> String {
let snakeCase = action.rawValue.replacingOccurrences(
of: "([a-z0-9])([A-Z])",
with: "$1_$2",
options: .regularExpression
)
return snakeCase.lowercased()
}
private func debugShortcutAction(named rawName: String) -> KeyboardShortcutSettings.Action? {
let normalized = rawName
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "-", with: "_")
for action in KeyboardShortcutSettings.Action.allCases {
let snakeCaseName = debugShortcutName(for: action)
if normalized == snakeCaseName || normalized == snakeCaseName.replacingOccurrences(of: "_", with: "") {
return action
}
}
return nil
}
private func debugShortcutSupportedNames() -> String {
KeyboardShortcutSettings.Action.allCases
.map(debugShortcutName(for:))
.sorted()
.joined(separator: ", ")
}
private func setShortcut(_ args: String) -> String {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
guard parts.count == 2 else {
return "ERROR: Usage: set_shortcut <name> <combo|clear>"
}
let name = parts[0]
let combo = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard let action = debugShortcutAction(named: name) else {
return "ERROR: Unknown shortcut name. Supported: \(debugShortcutSupportedNames())"
}
if combo.lowercased() == "clear" || combo.lowercased() == "default" || combo.lowercased() == "reset" {
UserDefaults.standard.removeObject(forKey: action.defaultsKey)
return "OK"
}
guard let parsed = parseShortcutCombo(combo) else {
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
}
let shortcut = StoredShortcut(
key: parsed.storedKey,
command: parsed.modifierFlags.contains(.command),
shift: parsed.modifierFlags.contains(.shift),
option: parsed.modifierFlags.contains(.option),
control: parsed.modifierFlags.contains(.control)
)
guard let data = try? JSONEncoder().encode(shortcut) else {
return "ERROR: Failed to encode shortcut"
}
UserDefaults.standard.set(data, forKey: action.defaultsKey)
return "OK"
}
private func simulateShortcut(_ args: String) -> String {
let combo = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !combo.isEmpty else {
return "ERROR: Usage: simulate_shortcut <combo>"
}
guard let parsed = parseShortcutCombo(combo) else {
return "ERROR: Invalid combo. Example: cmd+ctrl+h"
}
// Stamp at socket-handler arrival so event.timestamp includes any wait
// before the main-thread event dispatch.
let requestTimestamp = ProcessInfo.processInfo.systemUptime
var result = "ERROR: Failed to create event"
DispatchQueue.main.sync {
// Prefer the current active-tab-manager window so shortcut simulation stays
// scoped to the intended window even when NSApp.keyWindow is stale.
let targetWindow: NSWindow? = {
if let activeTabManager = self.tabManager,
let windowId = AppDelegate.shared?.windowId(for: activeTabManager),
let window = AppDelegate.shared?.mainWindow(for: windowId) {
return window
}
return NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first
}()
if let targetWindow {
NSApp.activate(ignoringOtherApps: true)
targetWindow.makeKeyAndOrderFront(nil)
}
let windowNumber = targetWindow?.windowNumber ?? 0
guard let keyDownEvent = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: requestTimestamp,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
isARepeat: false,
keyCode: parsed.keyCode
) else {
result = "ERROR: NSEvent.keyEvent returned nil"
return
}
let keyUpEvent = NSEvent.keyEvent(
with: .keyUp,
location: .zero,
modifierFlags: parsed.modifierFlags,
timestamp: requestTimestamp + 0.0001,
windowNumber: windowNumber,
context: nil,
characters: parsed.characters,
charactersIgnoringModifiers: parsed.charactersIgnoringModifiers,
isARepeat: false,
keyCode: parsed.keyCode
)
// Socket-driven shortcut simulation should reuse the exact same matching logic as the
// app-level shortcut monitor (so tests are hermetic), while still falling back to the
// normal responder chain for plain typing.
if let delegate = AppDelegate.shared, delegate.debugHandleCustomShortcut(event: keyDownEvent) {
result = "OK"
return
}
NSApp.sendEvent(keyDownEvent)
if let keyUpEvent {
NSApp.sendEvent(keyUpEvent)
}
result = "OK"
}
return result
}
private func activateApp() -> String {
DispatchQueue.main.sync {
NSApp.activate(ignoringOtherApps: true)
NSApp.unhide(nil)
let hasMainTerminalWindow = NSApp.windows.contains { window in
guard let raw = window.identifier?.rawValue else { return false }
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
}
if !hasMainTerminalWindow {
AppDelegate.shared?.openNewMainWindow(nil)
}
if let window = NSApp.mainWindow
?? NSApp.keyWindow
?? NSApp.windows.first(where: { win in
guard let raw = win.identifier?.rawValue else { return false }
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
})
?? NSApp.windows.first {
window.makeKeyAndOrderFront(nil)
}
}
return "OK"
}
private func simulateType(_ args: String) -> String {
let raw = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
return "ERROR: Usage: simulate_type <text>"
}
// Socket commands are line-based; allow callers to express control chars with backslash escapes.
let text = unescapeSocketText(raw)
var result = "ERROR: No window"
DispatchQueue.main.sync {
// Like simulate_shortcut, prefer a visible window so debug automation doesn't
// fail during key window transitions.
guard let window = NSApp.keyWindow
?? NSApp.mainWindow
?? NSApp.windows.first(where: { $0.isVisible })
?? NSApp.windows.first else { return }
NSApp.activate(ignoringOtherApps: true)
window.makeKeyAndOrderFront(nil)
guard let fr = window.firstResponder else {
result = "ERROR: No first responder"
return
}
if let client = fr as? NSTextInputClient {
client.insertText(text, replacementRange: NSRange(location: NSNotFound, length: 0))
result = "OK"
return
}
// If workspace handoff temporarily leaves a non-terminal first responder,
// route debug typing to the selected terminal's focused panel directly.
if let tabManager,
let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = tab.focusedPanelId,
let terminalPanel = tab.terminalPanel(for: panelId),
!terminalPanel.hostedView.isSurfaceViewFirstResponder() {
// Match Enter semantics expected by tests/debug tooling when bypassing AppKit.
let directText = text.replacingOccurrences(of: "\n", with: "\r")
terminalPanel.surface.sendText(directText)
result = "OK"
return
}
// Fall back to the responder-chain insertText action.
(fr as? NSResponder)?.insertText(text)
result = "OK"
}
return result
}
private func simulateFileDrop(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
guard parts.count == 2 else {
return "ERROR: Usage: simulate_file_drop <id|idx> <path[|path...]>"
}
let target = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
let rawPaths = parts[1]
let paths = rawPaths
.split(separator: "|")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !paths.isEmpty else {
return "ERROR: Usage: simulate_file_drop <id|idx> <path[|path...]>"
}
var result = "ERROR: Surface not found"
DispatchQueue.main.sync {
guard let panel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return }
result = panel.hostedView.debugSimulateFileDrop(paths: paths)
? "OK"
: "ERROR: Failed to simulate drop"
}
return result
}
private func seedDragPasteboardFileURL() -> String {
return seedDragPasteboardTypes("fileurl")
}
private func seedDragPasteboardTabTransfer() -> String {
return seedDragPasteboardTypes("tabtransfer")
}
private func seedDragPasteboardSidebarReorder() -> String {
return seedDragPasteboardTypes("sidebarreorder")
}
private func seedDragPasteboardTypes(_ args: String) -> String {
let raw = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
return "ERROR: Usage: seed_drag_pasteboard_types <type[,type...]>"
}
let tokens = raw
.split(whereSeparator: { $0 == "," || $0.isWhitespace })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !tokens.isEmpty else {
return "ERROR: Usage: seed_drag_pasteboard_types <type[,type...]>"
}
var types: [NSPasteboard.PasteboardType] = []
for token in tokens {
guard let mapped = dragPasteboardType(from: token) else {
return "ERROR: Unknown drag type '\(token)'"
}
if !types.contains(mapped) {
types.append(mapped)
}
}
DispatchQueue.main.sync {
_ = NSPasteboard(name: .drag).declareTypes(types, owner: nil)
}
return "OK"
}
private func clearDragPasteboard() -> String {
DispatchQueue.main.sync {
_ = NSPasteboard(name: .drag).clearContents()
}
return "OK"
}
private func overlayHitGate(_ args: String) -> String {
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !token.isEmpty else {
return "ERROR: Usage: overlay_hit_gate <leftMouseDragged|rightMouseDragged|otherMouseDragged|mouseMoved|mouseEntered|mouseExited|flagsChanged|cursorUpdate|appKitDefined|systemDefined|applicationDefined|periodic|leftMouseDown|leftMouseUp|rightMouseDown|rightMouseUp|otherMouseDown|otherMouseUp|scrollWheel|none>"
}
let parsedEvent = parseOverlayEventType(token)
guard parsedEvent.isKnown else {
return "ERROR: Unknown event type '\(args.trimmingCharacters(in: .whitespacesAndNewlines))'"
}
let eventType = parsedEvent.eventType
var shouldCapture = false
DispatchQueue.main.sync {
let pb = NSPasteboard(name: .drag)
shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropOverlay(
pasteboardTypes: pb.types,
eventType: eventType
)
}
return shouldCapture ? "true" : "false"
}
private func overlayDropGate(_ args: String) -> String {
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hasLocalDraggingSource: Bool
switch token {
case "", "external":
hasLocalDraggingSource = false
case "local":
hasLocalDraggingSource = true
default:
return "ERROR: Usage: overlay_drop_gate [external|local]"
}
var shouldCapture = false
DispatchQueue.main.sync {
let pb = NSPasteboard(name: .drag)
shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
pasteboardTypes: pb.types,
hasLocalDraggingSource: hasLocalDraggingSource
)
}
return shouldCapture ? "true" : "false"
}
private func portalHitGate(_ args: String) -> String {
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !token.isEmpty else {
return "ERROR: Usage: portal_hit_gate <leftMouseDragged|rightMouseDragged|otherMouseDragged|mouseMoved|mouseEntered|mouseExited|flagsChanged|cursorUpdate|appKitDefined|systemDefined|applicationDefined|periodic|leftMouseDown|leftMouseUp|rightMouseDown|rightMouseUp|otherMouseDown|otherMouseUp|scrollWheel|none>"
}
let parsedEvent = parseOverlayEventType(token)
guard parsedEvent.isKnown else {
return "ERROR: Unknown event type '\(args.trimmingCharacters(in: .whitespacesAndNewlines))'"
}
let eventType = parsedEvent.eventType
var shouldPassThrough = false
DispatchQueue.main.sync {
let pb = NSPasteboard(name: .drag)
shouldPassThrough = DragOverlayRoutingPolicy.shouldPassThroughPortalHitTesting(
pasteboardTypes: pb.types,
eventType: eventType
)
}
return shouldPassThrough ? "true" : "false"
}
private func sidebarOverlayGate(_ args: String) -> String {
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let hasSidebarDragState: Bool
switch token {
case "", "active":
hasSidebarDragState = true
case "inactive":
hasSidebarDragState = false
default:
return "ERROR: Usage: sidebar_overlay_gate [active|inactive]"
}
var shouldCapture = false
DispatchQueue.main.sync {
let pb = NSPasteboard(name: .drag)
shouldCapture = DragOverlayRoutingPolicy.shouldCaptureSidebarExternalOverlay(
hasSidebarDragState: hasSidebarDragState,
pasteboardTypes: pb.types
)
}
return shouldCapture ? "true" : "false"
}
private func terminalDropOverlayProbe(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let token = args.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let useDeferredPath: Bool
switch token {
case "", "deferred":
useDeferredPath = true
case "direct":
useDeferredPath = false
default:
return "ERROR: Usage: terminal_drop_overlay_probe [deferred|direct]"
}
var result = "ERROR: No selected workspace"
DispatchQueue.main.sync {
guard let selectedId = tabManager.selectedTabId,
let workspace = tabManager.tabs.first(where: { $0.id == selectedId }) else {
return
}
let terminalPanel = workspace.focusedTerminalPanel
?? orderedPanels(in: workspace).compactMap { $0 as? TerminalPanel }.first
guard let terminalPanel else {
result = "ERROR: No terminal panel available"
return
}
let probe = terminalPanel.hostedView.debugProbeDropOverlayAnimation(
useDeferredPath: useDeferredPath
)
let animated = probe.after > probe.before
let mode = useDeferredPath ? "deferred" : "direct"
result = String(
format: "OK mode=%@ animated=%d before=%d after=%d bounds=%.1fx%.1f",
mode,
animated ? 1 : 0,
probe.before,
probe.after,
probe.bounds.width,
probe.bounds.height
)
}
return result
}
private func parseOverlayEventType(_ token: String) -> (isKnown: Bool, eventType: NSEvent.EventType?) {
switch token {
case "leftmousedragged":
return (true, .leftMouseDragged)
case "rightmousedragged":
return (true, .rightMouseDragged)
case "othermousedragged":
return (true, .otherMouseDragged)
case "mousemove", "mousemoved":
return (true, .mouseMoved)
case "mouseentered":
return (true, .mouseEntered)
case "mouseexited":
return (true, .mouseExited)
case "flagschanged":
return (true, .flagsChanged)
case "cursorupdate":
return (true, .cursorUpdate)
case "appkitdefined":
return (true, .appKitDefined)
case "systemdefined":
return (true, .systemDefined)
case "applicationdefined":
return (true, .applicationDefined)
case "periodic":
return (true, .periodic)
case "leftmousedown":
return (true, .leftMouseDown)
case "leftmouseup":
return (true, .leftMouseUp)
case "rightmousedown":
return (true, .rightMouseDown)
case "rightmouseup":
return (true, .rightMouseUp)
case "othermousedown":
return (true, .otherMouseDown)
case "othermouseup":
return (true, .otherMouseUp)
case "scrollwheel":
return (true, .scrollWheel)
case "none":
return (true, nil)
default:
return (false, nil)
}
}
private func dragPasteboardType(from token: String) -> NSPasteboard.PasteboardType? {
let normalized = token.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch normalized {
case "fileurl", "file-url", "public.file-url":
return .fileURL
case "tabtransfer", "tab-transfer", "com.splittabbar.tabtransfer":
return DragOverlayRoutingPolicy.bonsplitTabTransferType
case "sidebarreorder", "sidebar-reorder", "sidebar_tab_reorder",
"com.cmux.sidebar-tab-reorder":
return DragOverlayRoutingPolicy.sidebarTabReorderType
default:
// Allow explicit UTI strings for ad-hoc debug probes.
guard token.contains(".") else { return nil }
return NSPasteboard.PasteboardType(token)
}
}
/// Hit-tests the file-drop overlay's coordinate-to-terminal mapping.
/// Takes normalised (0-1) x,y within the content area where (0,0) is the
/// top-left corner and (1,1) is the bottom-right corner. Returns the
/// surface UUID of the terminal under that point, or "none".
private func dropHitTest(_ args: String) -> String {
let parts = args.split(separator: " ").map(String.init)
guard parts.count == 2,
let nx = Double(parts[0]), let ny = Double(parts[1]),
(0...1).contains(nx), (0...1).contains(ny) else {
return "ERROR: Usage: drop_hit_test <x 0-1> <y 0-1>"
}
var result = "ERROR: No window"
DispatchQueue.main.sync {
guard let window = NSApp.mainWindow
?? NSApp.keyWindow
?? NSApp.windows.first(where: { win in
guard let raw = win.identifier?.rawValue else { return false }
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
}),
let contentView = window.contentView,
let themeFrame = contentView.superview else { return }
// Convert normalized top-left coordinates into a window point.
let pointInTheme = NSPoint(
x: contentView.frame.minX + (contentView.bounds.width * nx),
y: contentView.frame.maxY - (contentView.bounds.height * ny)
)
let windowPoint = themeFrame.convert(pointInTheme, to: nil)
if let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? FileDropOverlayView,
let terminal = overlay.terminalUnderPoint(windowPoint),
let surfaceId = terminal.terminalSurface?.id {
result = surfaceId.uuidString.uppercased()
return
}
result = "none"
}
return result
}
/// Return the hit-test chain at normalized (0-1) coordinates in the main window's
/// content area. Used by regression tests to detect root-level drag destinations
/// shadowing pane-local Bonsplit drop targets.
private func dragHitChain(_ args: String) -> String {
let parts = args.split(separator: " ").map(String.init)
guard parts.count == 2,
let nx = Double(parts[0]), let ny = Double(parts[1]),
(0...1).contains(nx), (0...1).contains(ny) else {
return "ERROR: Usage: drag_hit_chain <x 0-1> <y 0-1>"
}
var result = "ERROR: No window"
DispatchQueue.main.sync {
guard let window = NSApp.mainWindow
?? NSApp.keyWindow
?? NSApp.windows.first(where: { win in
guard let raw = win.identifier?.rawValue else { return false }
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
}),
let contentView = window.contentView,
let themeFrame = contentView.superview else { return }
let pointInTheme = NSPoint(
x: contentView.frame.minX + (contentView.bounds.width * nx),
y: contentView.frame.maxY - (contentView.bounds.height * ny)
)
let overlay = objc_getAssociatedObject(window, &fileDropOverlayKey) as? NSView
if let overlay { overlay.isHidden = true }
defer { overlay?.isHidden = false }
guard let hit = themeFrame.hitTest(pointInTheme) else {
result = "none"
return
}
var chain: [String] = []
var current: NSView? = hit
var depth = 0
while let view = current, depth < 8 {
chain.append(debugDragHitViewDescriptor(view))
current = view.superview
depth += 1
}
result = chain.joined(separator: "->")
}
return result
}
private func debugDragHitViewDescriptor(_ view: NSView) -> String {
let className = String(describing: type(of: view))
let pointer = String(describing: Unmanaged.passUnretained(view).toOpaque())
let types = view.registeredDraggedTypes
let renderedTypes: String
if types.isEmpty {
renderedTypes = "-"
} else {
let raw = types.map(\.rawValue)
renderedTypes = raw.count <= 4
? raw.joined(separator: ",")
: raw.prefix(4).joined(separator: ",") + ",+\(raw.count - 4)"
}
return "\(className)@\(pointer){dragTypes=\(renderedTypes)}"
}
private func unescapeSocketText(_ input: String) -> String {
var out = ""
var escaping = false
for ch in input {
if escaping {
switch ch {
case "n":
out.append("\n")
case "r":
out.append("\r")
case "t":
out.append("\t")
case "\\":
out.append("\\")
default:
out.append("\\")
out.append(ch)
}
escaping = false
} else if ch == "\\" {
escaping = true
} else {
out.append(ch)
}
}
if escaping {
out.append("\\")
}
return out
}
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var r = start
var hops = 0
while let cur = r, hops < 64 {
if cur === target { return true }
r = cur.nextResponder
hops += 1
}
return false
}
private func isTerminalFocused(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: is_terminal_focused <panel_id|idx>" }
var result = "false"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
result = "false"
return
}
guard let panelId = resolveSurfaceId(from: panelArg, tab: tab),
let terminalPanel = tab.terminalPanel(for: panelId) else {
result = "false"
return
}
result = terminalPanel.hostedView.isSurfaceViewFirstResponder() ? "true" : "false"
}
return result
}
private func readTerminalText(_ args: String) -> String {
readTerminalTextBase64(surfaceArg: args)
}
private struct RenderStatsResponse: Codable {
let panelId: String
let drawCount: Int
let lastDrawTime: Double
let metalDrawableCount: Int
let metalLastDrawableTime: Double
let presentCount: Int
let lastPresentTime: Double
let layerClass: String
let layerContentsKey: String
let inWindow: Bool
let windowIsKey: Bool
let windowOcclusionVisible: Bool
let appIsActive: Bool
let isActive: Bool
let desiredFocus: Bool
let isFirstResponder: Bool
}
private func renderStats(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
let panelId: UUID?
if panelArg.isEmpty {
panelId = tab.focusedPanelId
} else {
panelId = resolveSurfaceId(from: panelArg, tab: tab)
}
guard let panelId,
let terminalPanel = tab.terminalPanel(for: panelId) else {
result = "ERROR: Terminal surface not found"
return
}
let stats = terminalPanel.hostedView.debugRenderStats()
let payload = RenderStatsResponse(
panelId: panelId.uuidString,
drawCount: stats.drawCount,
lastDrawTime: stats.lastDrawTime,
metalDrawableCount: stats.metalDrawableCount,
metalLastDrawableTime: stats.metalLastDrawableTime,
presentCount: stats.presentCount,
lastPresentTime: stats.lastPresentTime,
layerClass: stats.layerClass,
layerContentsKey: stats.layerContentsKey,
inWindow: stats.inWindow,
windowIsKey: stats.windowIsKey,
windowOcclusionVisible: stats.windowOcclusionVisible,
appIsActive: stats.appIsActive,
isActive: stats.isActive,
desiredFocus: stats.desiredFocus,
isFirstResponder: stats.isFirstResponder
)
let encoder = JSONEncoder()
guard let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8) else {
result = "ERROR: Failed to encode render_stats"
return
}
result = "OK \(json)"
}
return result
}
private struct ParsedShortcutCombo {
let storedKey: String
let keyCode: UInt16
let modifierFlags: NSEvent.ModifierFlags
let characters: String
let charactersIgnoringModifiers: String
}
private func parseShortcutCombo(_ combo: String) -> ParsedShortcutCombo? {
let raw = combo.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else { return nil }
let parts = raw
.split(separator: "+")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
guard !parts.isEmpty else { return nil }
var flags: NSEvent.ModifierFlags = []
var keyToken: String?
for part in parts {
let lower = part.lowercased()
switch lower {
case "cmd", "command", "super":
flags.insert(.command)
case "ctrl", "control":
flags.insert(.control)
case "opt", "option", "alt":
flags.insert(.option)
case "shift":
flags.insert(.shift)
default:
// Treat as the key component.
if keyToken == nil {
keyToken = part
} else {
// Multiple non-modifier tokens is ambiguous.
return nil
}
}
}
guard var keyToken else { return nil }
keyToken = keyToken.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyToken.isEmpty else { return nil }
// Normalize a few named keys.
let storedKey: String
let keyCode: UInt16
let charactersIgnoringModifiers: String
switch keyToken.lowercased() {
case "esc", "escape":
storedKey = "\u{1b}"
keyCode = UInt16(kVK_Escape)
charactersIgnoringModifiers = storedKey
case "left":
storedKey = ""
keyCode = 123
charactersIgnoringModifiers = storedKey
case "right":
storedKey = ""
keyCode = 124
charactersIgnoringModifiers = storedKey
case "down":
storedKey = ""
keyCode = 125
charactersIgnoringModifiers = storedKey
case "up":
storedKey = ""
keyCode = 126
charactersIgnoringModifiers = storedKey
case "enter", "return":
storedKey = "\r"
keyCode = UInt16(kVK_Return)
charactersIgnoringModifiers = storedKey
case "backspace", "delete", "del":
storedKey = "\u{7f}"
keyCode = UInt16(kVK_Delete)
charactersIgnoringModifiers = storedKey
default:
let key = keyToken.lowercased()
guard let code = keyCodeForShortcutKey(key) else { return nil }
storedKey = key
keyCode = code
// Replicate a common system behavior: Ctrl+letter yields a control character in
// charactersIgnoringModifiers (e.g. Ctrl+H => backspace). This is important for
// testing keyCode fallback matching.
if flags.contains(.control),
key.count == 1,
let scalar = key.unicodeScalars.first,
scalar.isASCII,
scalar.value >= 97, scalar.value <= 122 { // a-z
let upper = scalar.value - 32
let controlValue = upper - 64 // 'A' => 1
charactersIgnoringModifiers = String(UnicodeScalar(controlValue)!)
} else {
charactersIgnoringModifiers = storedKey
}
}
// For our shortcut matcher, characters aren't important beyond exercising edge cases.
let chars = charactersIgnoringModifiers
return ParsedShortcutCombo(
storedKey: storedKey,
keyCode: keyCode,
modifierFlags: flags,
characters: chars,
charactersIgnoringModifiers: charactersIgnoringModifiers
)
}
private func keyCodeForShortcutKey(_ key: String) -> UInt16? {
// Matches macOS ANSI key codes for common printable keys and a few named specials.
switch key {
case "a": return 0 // kVK_ANSI_A
case "s": return 1 // kVK_ANSI_S
case "d": return 2 // kVK_ANSI_D
case "f": return 3 // kVK_ANSI_F
case "h": return 4 // kVK_ANSI_H
case "g": return 5 // kVK_ANSI_G
case "z": return 6 // kVK_ANSI_Z
case "x": return 7 // kVK_ANSI_X
case "c": return 8 // kVK_ANSI_C
case "v": return 9 // kVK_ANSI_V
case "b": return 11 // kVK_ANSI_B
case "q": return 12 // kVK_ANSI_Q
case "w": return 13 // kVK_ANSI_W
case "e": return 14 // kVK_ANSI_E
case "r": return 15 // kVK_ANSI_R
case "y": return 16 // kVK_ANSI_Y
case "t": return 17 // kVK_ANSI_T
case "1": return 18 // kVK_ANSI_1
case "2": return 19 // kVK_ANSI_2
case "3": return 20 // kVK_ANSI_3
case "4": return 21 // kVK_ANSI_4
case "6": return 22 // kVK_ANSI_6
case "5": return 23 // kVK_ANSI_5
case "=": return 24 // kVK_ANSI_Equal
case "9": return 25 // kVK_ANSI_9
case "7": return 26 // kVK_ANSI_7
case "-": return 27 // kVK_ANSI_Minus
case "8": return 28 // kVK_ANSI_8
case "0": return 29 // kVK_ANSI_0
case "]": return 30 // kVK_ANSI_RightBracket
case "o": return 31 // kVK_ANSI_O
case "u": return 32 // kVK_ANSI_U
case "[": return 33 // kVK_ANSI_LeftBracket
case "i": return 34 // kVK_ANSI_I
case "p": return 35 // kVK_ANSI_P
case "l": return 37 // kVK_ANSI_L
case "j": return 38 // kVK_ANSI_J
case "'": return 39 // kVK_ANSI_Quote
case "k": return 40 // kVK_ANSI_K
case ";": return 41 // kVK_ANSI_Semicolon
case "\\": return 42 // kVK_ANSI_Backslash
case ",": return 43 // kVK_ANSI_Comma
case "/": return 44 // kVK_ANSI_Slash
case "n": return 45 // kVK_ANSI_N
case "m": return 46 // kVK_ANSI_M
case ".": return 47 // kVK_ANSI_Period
case "`": return 50 // kVK_ANSI_Grave
default:
return nil
}
}
#endif
#if !DEBUG
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var responder = start
var hops = 0
while let current = responder, hops < 64 {
if current === target { return true }
responder = current.nextResponder
hops += 1
}
return false
}
#endif
private func listWindows() -> String {
let summaries = v2MainSync { AppDelegate.shared?.listMainWindowSummaries() } ?? []
guard !summaries.isEmpty else { return "No windows" }
let lines = summaries.enumerated().map { idx, item in
let selected = item.isKeyWindow ? "*" : " "
let selectedWs = item.selectedWorkspaceId?.uuidString ?? "none"
return "\(selected) \(idx): \(item.windowId.uuidString) selected_workspace=\(selectedWs) workspaces=\(item.workspaceCount)"
}
return lines.joined(separator: "\n")
}
private func currentWindow() -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
guard let windowId = v2ResolveWindowId(tabManager: tabManager) else { return "ERROR: No active window" }
return windowId.uuidString
}
private func focusWindow(_ arg: String) -> String {
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard let windowId = UUID(uuidString: trimmed) else { return "ERROR: Invalid window id" }
let ok = v2MainSync { AppDelegate.shared?.focusMainWindow(windowId: windowId) ?? false }
guard ok else { return "ERROR: Window not found" }
if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) {
setActiveTabManager(tm)
}
return "OK"
}
private func newWindow() -> String {
guard let windowId = v2MainSync({ AppDelegate.shared?.createMainWindow() }) else {
return "ERROR: Failed to create window"
}
if socketCommandAllowsInAppFocusMutations(),
let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) {
setActiveTabManager(tm)
}
return "OK \(windowId.uuidString)"
}
private func closeWindow(_ arg: String) -> String {
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard let windowId = UUID(uuidString: trimmed) else { return "ERROR: Invalid window id" }
let ok = v2MainSync { AppDelegate.shared?.closeMainWindow(windowId: windowId) ?? false }
return ok ? "OK" : "ERROR: Window not found"
}
private func moveWorkspaceToWindow(_ args: String) -> String {
let parts = args.split(separator: " ").map(String.init)
guard parts.count >= 2 else { return "ERROR: Usage move_workspace_to_window <workspace_id> <window_id>" }
guard let wsId = UUID(uuidString: parts[0]) else { return "ERROR: Invalid workspace id" }
guard let windowId = UUID(uuidString: parts[1]) else { return "ERROR: Invalid window id" }
var ok = false
let focus = socketCommandAllowsInAppFocusMutations()
v2MainSync {
guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: wsId),
let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowId),
let ws = srcTM.detachWorkspace(tabId: wsId) else {
ok = false
return
}
dstTM.attachWorkspace(ws, select: focus)
if focus {
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
setActiveTabManager(dstTM)
}
ok = true
}
return ok ? "OK" : "ERROR: Move failed"
}
private func listWorkspaces() -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result: String = ""
DispatchQueue.main.sync {
let tabs = tabManager.tabs.enumerated().map { (index, tab) in
let selected = tab.id == tabManager.selectedTabId ? "*" : " "
return "\(selected) \(index): \(tab.id.uuidString) \(tab.title)"
}
result = tabs.joined(separator: "\n")
}
return result.isEmpty ? "No workspaces" : result
}
private func newWorkspace() -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var newTabId: UUID?
let focus = socketCommandAllowsInAppFocusMutations()
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
DispatchQueue.main.sync {
let workspace = tabManager.addTab(select: focus)
newTabId = workspace.id
}
#if DEBUG
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
dlog(
"socket.new_workspace focus=\(focus ? 1 : 0) ms=\(String(format: "%.2f", elapsedMs)) main=\(Thread.isMainThread ? 1 : 0)"
)
#endif
return "OK \(newTabId?.uuidString ?? "unknown")"
}
private func newSplit(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
guard !parts.isEmpty else {
return "ERROR: Invalid direction. Use left, right, up, or down."
}
let directionArg = parts[0]
let panelArg = parts.count > 1 ? parts[1] : ""
guard let direction = parseSplitDirection(directionArg) else {
return "ERROR: Invalid direction. Use left, right, up, or down."
}
var result = "ERROR: Failed to create split"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
// If panel arg provided, resolve it; otherwise use focused panel
let surfaceId: UUID?
if !panelArg.isEmpty {
surfaceId = resolveSurfaceId(from: panelArg, tab: tab)
if surfaceId == nil {
result = "ERROR: Panel not found"
return
}
} else {
surfaceId = tab.focusedPanelId
}
guard let targetSurface = surfaceId else {
result = "ERROR: No surface to split"
return
}
if let newPanelId = tabManager.newSplit(
tabId: tabId,
surfaceId: targetSurface,
direction: direction,
focus: socketCommandAllowsInAppFocusMutations()
) {
result = "OK \(newPanelId.uuidString)"
}
}
return result
}
private func listSurfaces(_ tabArg: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
result = "ERROR: Tab not found"
return
}
let panels = orderedPanels(in: tab)
let focusedId = tab.focusedPanelId
let lines = panels.enumerated().map { index, panel in
let selected = panel.id == focusedId ? "*" : " "
return "\(selected) \(index): \(panel.id.uuidString)"
}
result = lines.isEmpty ? "No surfaces" : lines.joined(separator: "\n")
}
return result
}
private func focusSurface(_ arg: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Missing panel id or index" }
var success = false
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
if let uuid = UUID(uuidString: trimmed),
tab.panels[uuid] != nil {
guard tab.surfaceIdFromPanelId(uuid) != nil else { return }
tabManager.focusSurface(tabId: tab.id, surfaceId: uuid)
success = true
return
}
if let index = Int(trimmed), index >= 0 {
let panels = orderedPanels(in: tab)
guard index < panels.count else { return }
guard tab.surfaceIdFromPanelId(panels[index].id) != nil else { return }
tabManager.focusSurface(tabId: tab.id, surfaceId: panels[index].id)
success = true
}
}
return success ? "OK" : "ERROR: Panel not found"
}
private func notifyCurrent(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId else {
result = "ERROR: No tab selected"
return
}
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
let (title, subtitle, body) = parseNotificationPayload(args)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
body: body
)
}
return result
}
private func notifySurface(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
let surfaceArg = parts[0]
let payload = parts.count > 1 ? parts[1] : ""
var result = "OK"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
result = "ERROR: No tab selected"
return
}
guard let surfaceId = resolveSurfaceId(from: surfaceArg, tab: tab) else {
result = "ERROR: Surface not found"
return
}
let (title, subtitle, body) = parseNotificationPayload(payload)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
title: title,
subtitle: subtitle,
body: body
)
}
return result
}
private func notifyTarget(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Usage: notify_target <workspace_id> <surface_id> <title>|<subtitle>|<body>" }
let parts = trimmed.split(separator: " ", maxSplits: 2).map(String.init)
guard parts.count >= 2 else { return "ERROR: Usage: notify_target <workspace_id> <surface_id> <title>|<subtitle>|<body>" }
let tabArg = parts[0]
let panelArg = parts[1]
let payload = parts.count > 2 ? parts[2] : ""
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
result = "ERROR: Tab not found"
return
}
guard let panelId = UUID(uuidString: panelArg),
tab.panels[panelId] != nil else {
result = "ERROR: Panel not found"
return
}
let (title, subtitle, body) = parseNotificationPayload(payload)
TerminalNotificationStore.shared.addNotification(
tabId: tab.id,
surfaceId: panelId,
title: title,
subtitle: subtitle,
body: body
)
}
return result
}
private func listNotifications() -> String {
var result = ""
DispatchQueue.main.sync {
let lines = TerminalNotificationStore.shared.notifications.enumerated().map { index, notification in
let surfaceText = notification.surfaceId?.uuidString ?? "none"
let readText = notification.isRead ? "read" : "unread"
return "\(index):\(notification.id.uuidString)|\(notification.tabId.uuidString)|\(surfaceText)|\(readText)|\(notification.title)|\(notification.subtitle)|\(notification.body)"
}
result = lines.joined(separator: "\n")
}
return result.isEmpty ? "No notifications" : result
}
private func clearNotifications(_ args: String) -> String {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
DispatchQueue.main.sync {
TerminalNotificationStore.shared.clearAll()
}
return "OK"
}
let parsed = parseOptions(trimmed)
guard let tabOption = parsed.options["tab"],
!tabOption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return "ERROR: Usage: clear_notifications [--tab=X]"
}
var tabId: UUID?
DispatchQueue.main.sync {
if let tab = resolveTabForReport(trimmed) {
tabId = tab.id
}
}
guard let tabId else {
return "ERROR: Tab not found"
}
DispatchQueue.main.sync {
TerminalNotificationStore.shared.clearNotifications(forTabId: tabId)
}
return "OK"
}
private func setAppFocusOverride(_ arg: String) -> String {
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
switch trimmed {
case "active", "1", "true":
AppFocusState.overrideIsFocused = true
return "OK"
case "inactive", "0", "false":
AppFocusState.overrideIsFocused = false
return "OK"
case "clear", "none", "":
AppFocusState.overrideIsFocused = nil
return "OK"
default:
return "ERROR: Expected active, inactive, or clear"
}
}
private func simulateAppDidBecomeActive() -> String {
DispatchQueue.main.sync {
AppDelegate.shared?.applicationDidBecomeActive(
Notification(name: NSApplication.didBecomeActiveNotification)
)
}
return "OK"
}
#if DEBUG
private func focusFromNotification(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
let tabArg = parts.first ?? ""
let surfaceArg = parts.count > 1 ? parts[1] : ""
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
result = "ERROR: Tab not found"
return
}
let surfaceId = surfaceArg.isEmpty ? nil : resolveSurfaceId(from: surfaceArg, tab: tab)
if !surfaceArg.isEmpty && surfaceId == nil {
result = "ERROR: Surface not found"
return
}
tabManager.focusTabFromNotification(tab.id, surfaceId: surfaceId)
}
return result
}
private func flashCount(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Missing surface id or index" }
var result = "ERROR: Surface not found"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
result = "ERROR: No tab selected"
return
}
guard let surfaceId = resolveSurfaceId(from: trimmed, tab: tab) else {
result = "ERROR: Surface not found"
return
}
let count = GhosttySurfaceScrollView.flashCount(for: surfaceId)
result = "OK \(count)"
}
return result
}
private func resetFlashCounts() -> String {
DispatchQueue.main.sync {
GhosttySurfaceScrollView.resetFlashCounts()
}
return "OK"
}
#if DEBUG
private struct PanelSnapshotState: Sendable {
let width: Int
let height: Int
let bytesPerRow: Int
let rgba: Data
}
/// Most tests run single-threaded but socket handlers can be invoked concurrently.
/// Keep snapshot bookkeeping simple and thread-safe.
private static let panelSnapshotLock = NSLock()
private static var panelSnapshots: [UUID: PanelSnapshotState] = [:]
private func panelSnapshotReset(_ args: String) -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: panel_snapshot_reset <panel_id|idx>" }
var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
guard let panelId = resolveSurfaceId(from: panelArg, tab: tab) else {
result = "ERROR: Surface not found"
return
}
Self.panelSnapshotLock.lock()
Self.panelSnapshots.removeValue(forKey: panelId)
Self.panelSnapshotLock.unlock()
result = "OK"
}
return result
}
private static func makePanelSnapshot(from cgImage: CGImage) -> PanelSnapshotState? {
let width = cgImage.width
let height = cgImage.height
guard width > 0, height > 0 else { return nil }
let bytesPerPixel = 4
let bytesPerRow = width * bytesPerPixel
var data = Data(count: bytesPerRow * height)
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue
let ok: Bool = data.withUnsafeMutableBytes { rawBuf in
guard let base = rawBuf.baseAddress else { return false }
guard let ctx = CGContext(
data: base,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: bitmapInfo
) else { return false }
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
return true
}
guard ok else { return nil }
return PanelSnapshotState(width: width, height: height, bytesPerRow: bytesPerRow, rgba: data)
}
private static func countChangedPixels(previous: PanelSnapshotState, current: PanelSnapshotState) -> Int {
// Any mismatch means we can't sensibly diff; treat as a fresh snapshot.
guard previous.width == current.width,
previous.height == current.height,
previous.bytesPerRow == current.bytesPerRow else {
return -1
}
let threshold = 8 // ignore tiny per-channel jitter
var changed = 0
previous.rgba.withUnsafeBytes { prevRaw in
current.rgba.withUnsafeBytes { curRaw in
guard let prev = prevRaw.bindMemory(to: UInt8.self).baseAddress,
let cur = curRaw.bindMemory(to: UInt8.self).baseAddress else {
return
}
let count = min(prevRaw.count, curRaw.count)
var i = 0
while i + 3 < count {
let dr = abs(Int(prev[i]) - Int(cur[i]))
let dg = abs(Int(prev[i + 1]) - Int(cur[i + 1]))
let db = abs(Int(prev[i + 2]) - Int(cur[i + 2]))
// Skip alpha channel at i+3.
if dr + dg + db > threshold {
changed += 1
}
i += 4
}
}
}
return changed
}
private func panelSnapshot(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "ERROR: Usage: panel_snapshot <panel_id|idx> [label]" }
let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init)
let panelArg = parts.first ?? ""
let label = parts.count > 1 ? parts[1] : ""
// Generate unique ID for this snapshot/screenshot
let timestamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: "+", with: "_")
let shortId = UUID().uuidString.prefix(8)
let snapshotId = "\(timestamp)_\(shortId)"
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-screenshots")
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
let filename = label.isEmpty ? "\(snapshotId).png" : "\(label)_\(snapshotId).png"
let outputPath = outputDir.appendingPathComponent(filename)
var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
guard let panelId = resolveSurfaceId(from: panelArg, tab: tab),
let terminalPanel = tab.terminalPanel(for: panelId) else {
result = "ERROR: Terminal surface not found"
return
}
// Capture the terminal's IOSurface directly, avoiding Screen Recording permissions.
let view = terminalPanel.hostedView
var cgImage = view.debugCopyIOSurfaceCGImage()
if cgImage == nil {
// If the surface is mid-attach we may not have contents yet. Nudge a draw and retry once.
terminalPanel.surface.forceRefresh()
cgImage = view.debugCopyIOSurfaceCGImage()
}
guard let cgImage else {
result = "ERROR: Failed to capture panel image"
return
}
guard let current = Self.makePanelSnapshot(from: cgImage) else {
result = "ERROR: Failed to read panel pixels"
return
}
var changedPixels = -1
Self.panelSnapshotLock.lock()
if let previous = Self.panelSnapshots[panelId] {
changedPixels = Self.countChangedPixels(previous: previous, current: current)
}
Self.panelSnapshots[panelId] = current
Self.panelSnapshotLock.unlock()
// Save PNG for postmortem debugging.
let bitmap = NSBitmapImageRep(cgImage: cgImage)
guard let pngData = bitmap.representation(using: .png, properties: [:]) else {
result = "ERROR: Failed to encode PNG"
return
}
do {
try pngData.write(to: outputPath)
} catch {
result = "ERROR: Failed to write file: \(error.localizedDescription)"
return
}
result = "OK \(panelId.uuidString) \(changedPixels) \(current.width) \(current.height) \(outputPath.path)"
}
return result
}
#endif
private struct LayoutDebugSelectedPanel: Codable, Sendable {
let paneId: String
let paneFrame: PixelRect?
let selectedTabId: String?
let panelId: String?
let panelType: String?
let inWindow: Bool?
let hidden: Bool?
let viewFrame: PixelRect?
let splitViews: [LayoutDebugSplitView]?
}
private struct LayoutDebugSplitView: Codable, Sendable {
let isVertical: Bool
let dividerThickness: Double
let bounds: PixelRect
let frame: PixelRect?
let arrangedSubviewFrames: [PixelRect]
let normalizedDividerPosition: Double?
}
private struct LayoutDebugResponse: Codable, Sendable {
let layout: LayoutSnapshot
let selectedPanels: [LayoutDebugSelectedPanel]
let mainWindowNumber: Int?
let keyWindowNumber: Int?
}
private func layoutDebug() -> String {
guard let tabManager else { return "ERROR: TabManager not available" }
var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
let layout = tab.bonsplitController.layoutSnapshot()
var paneFrames: [String: PixelRect] = [:]
for pane in layout.panes {
paneFrames[pane.paneId] = pane.frame
}
func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}
func windowFrame(for view: NSView) -> CGRect? {
guard view.window != nil else { return nil }
// Prefer the view's frame as laid out by its superview. Some AppKit views
// (notably scroll views) can temporarily report stale bounds during reparenting.
if let superview = view.superview {
return superview.convert(view.frame, to: nil)
}
return view.convert(view.bounds, to: nil)
}
func splitViewInfos(for view: NSView) -> [LayoutDebugSplitView] {
var infos: [LayoutDebugSplitView] = []
var current: NSView? = view
var depth = 0
while let v = current, depth < 12 {
if let sv = v as? NSSplitView {
// The split view can be mid-update during bonsplit structural changes; force a layout
// pass so our debug snapshot reflects the real state.
sv.layoutSubtreeIfNeeded()
let isVertical = sv.isVertical
let dividerThickness = Double(sv.dividerThickness)
let bounds = PixelRect(from: sv.bounds)
let frame = windowFrame(for: sv).map { PixelRect(from: $0) }
let arranged = sv.arrangedSubviews
let arrangedFrames = arranged.compactMap { windowFrame(for: $0).map { PixelRect(from: $0) } }
// Approximate divider position from the first arranged subview's size.
let totalSize: CGFloat = isVertical ? sv.bounds.width : sv.bounds.height
let availableSize = max(totalSize - sv.dividerThickness, 0)
var normalized: Double? = nil
if availableSize > 0, let first = arranged.first {
let dividerPos = isVertical ? first.frame.width : first.frame.height
normalized = Double(dividerPos / availableSize)
}
infos.append(LayoutDebugSplitView(
isVertical: isVertical,
dividerThickness: dividerThickness,
bounds: bounds,
frame: frame,
arrangedSubviewFrames: arrangedFrames,
normalizedDividerPosition: normalized
))
}
current = v.superview
depth += 1
}
return infos
}
let selectedPanels: [LayoutDebugSelectedPanel] = tab.bonsplitController.allPaneIds.map { paneId in
let paneIdStr = paneId.id.uuidString
let paneFrame = paneFrames[paneIdStr]
let selectedTabId = layout.panes.first(where: { $0.paneId == paneIdStr })?.selectedTabId
guard let selectedTab = tab.bonsplitController.selectedTab(inPane: paneId) else {
return LayoutDebugSelectedPanel(
paneId: paneIdStr,
paneFrame: paneFrame,
selectedTabId: selectedTabId,
panelId: nil,
panelType: nil,
inWindow: nil,
hidden: nil,
viewFrame: nil,
splitViews: nil
)
}
guard let panelId = tab.panelIdFromSurfaceId(selectedTab.id),
let panel = tab.panels[panelId] else {
return LayoutDebugSelectedPanel(
paneId: paneIdStr,
paneFrame: paneFrame,
selectedTabId: selectedTabId,
panelId: nil,
panelType: nil,
inWindow: nil,
hidden: nil,
viewFrame: nil,
splitViews: nil
)
}
if let tp = panel as? TerminalPanel {
let viewRect = windowFrame(for: tp.hostedView).map { PixelRect(from: $0) }
let splitViews = splitViewInfos(for: tp.hostedView)
return LayoutDebugSelectedPanel(
paneId: paneIdStr,
paneFrame: paneFrame,
selectedTabId: selectedTabId,
panelId: panelId.uuidString,
panelType: tp.panelType.rawValue,
inWindow: tp.surface.isViewInWindow,
hidden: isHiddenOrAncestorHidden(tp.hostedView),
viewFrame: viewRect,
splitViews: splitViews
)
}
if let bp = panel as? BrowserPanel {
let viewRect = windowFrame(for: bp.webView).map { PixelRect(from: $0) }
let splitViews = splitViewInfos(for: bp.webView)
return LayoutDebugSelectedPanel(
paneId: paneIdStr,
paneFrame: paneFrame,
selectedTabId: selectedTabId,
panelId: panelId.uuidString,
panelType: bp.panelType.rawValue,
inWindow: bp.webView.window != nil,
hidden: isHiddenOrAncestorHidden(bp.webView),
viewFrame: viewRect,
splitViews: splitViews
)
}
return LayoutDebugSelectedPanel(
paneId: paneIdStr,
paneFrame: paneFrame,
selectedTabId: selectedTabId,
panelId: panelId.uuidString,
panelType: panel.panelType.rawValue,
inWindow: nil,
hidden: nil,
viewFrame: nil,
splitViews: nil
)
}
let payload = LayoutDebugResponse(
layout: layout,
selectedPanels: selectedPanels,
mainWindowNumber: NSApp.mainWindow?.windowNumber,
keyWindowNumber: NSApp.keyWindow?.windowNumber
)
let encoder = JSONEncoder()
guard let data = try? encoder.encode(payload),
let json = String(data: data, encoding: .utf8) else {
result = "ERROR: Failed to encode layout_debug"
return
}
result = "OK \(json)"
}
return result
}
private func emptyPanelCount() -> String {
var result = "OK 0"
DispatchQueue.main.sync {
result = "OK \(DebugUIEventCounters.emptyPanelAppearCount)"
}
return result
}
private func resetEmptyPanelCount() -> String {
DispatchQueue.main.sync {
DebugUIEventCounters.resetEmptyPanelAppearCount()
}
return "OK"
}
private func bonsplitUnderflowCount() -> String {
var result = "OK 0"
DispatchQueue.main.sync {
#if DEBUG
result = "OK \(BonsplitDebugCounters.arrangedSubviewUnderflowCount)"
#else
result = "OK 0"
#endif
}
return result
}
private func resetBonsplitUnderflowCount() -> String {
DispatchQueue.main.sync {
#if DEBUG
BonsplitDebugCounters.reset()
#endif
}
return "OK"
}
private func captureScreenshot(_ args: String) -> String {
// Parse optional label from args
let label = args.trimmingCharacters(in: .whitespacesAndNewlines)
// Generate unique ID for this screenshot
let timestamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
.replacingOccurrences(of: "+", with: "_")
let shortId = UUID().uuidString.prefix(8)
let screenshotId = "\(timestamp)_\(shortId)"
// Determine output path
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-screenshots")
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
let filename = label.isEmpty ? "\(screenshotId).png" : "\(label)_\(screenshotId).png"
let outputPath = outputDir.appendingPathComponent(filename)
// Capture the main window on main thread
var captureError: String?
DispatchQueue.main.sync {
guard let window = NSApp.mainWindow ?? NSApp.windows.first else {
captureError = "No window available"
return
}
// Get window's CGWindowID
let windowNumber = CGWindowID(window.windowNumber)
// Capture the window using CGWindowListCreateImage
guard let cgImage = CGWindowListCreateImage(
.null, // Capture just the window bounds
.optionIncludingWindow,
windowNumber,
[.boundsIgnoreFraming, .nominalResolution]
) else {
captureError = "Failed to capture window image"
return
}
// Convert to NSBitmapImageRep and save as PNG
let bitmap = NSBitmapImageRep(cgImage: cgImage)
guard let pngData = bitmap.representation(using: .png, properties: [:]) else {
captureError = "Failed to create PNG data"
return
}
do {
try pngData.write(to: outputPath)
} catch {
captureError = "Failed to write file: \(error.localizedDescription)"
}
}
if let error = captureError {
return "ERROR: \(error)"
}
// Return OK with screenshot ID and path for easy reference
return "OK \(screenshotId) \(outputPath.path)"
}
#endif
private func parseSplitDirection(_ value: String) -> SplitDirection? {
switch value.lowercased() {
case "left", "l":
return .left
case "right", "r":
return .right
case "up", "u":
return .up
case "down", "d":
return .down
default:
return nil
}
}
private func resolveTab(from arg: String, tabManager: TabManager) -> Tab? {
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
guard let selected = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selected })
}
if let uuid = UUID(uuidString: trimmed) {
return tabManager.tabs.first(where: { $0.id == uuid })
}
if let index = Int(trimmed), index >= 0, index < tabManager.tabs.count {
return tabManager.tabs[index]
}
return nil
}
private func orderedPanels(in tab: Workspace) -> [any Panel] {
// Use bonsplit's tab ordering as the source of truth. This avoids relying on
// Dictionary iteration order, and prevents indexing into panels that aren't
// actually present in bonsplit anymore.
let orderedTabIds = tab.bonsplitController.allTabIds
var result: [any Panel] = []
var seen = Set<UUID>()
for tabId in orderedTabIds {
guard let panelId = tab.panelIdFromSurfaceId(tabId),
let panel = tab.panels[panelId] else { continue }
result.append(panel)
seen.insert(panelId)
}
// Defensive: include any orphaned panels in a stable order at the end.
let orphans = tab.panels.values
.filter { !seen.contains($0.id) }
.sorted { $0.id.uuidString < $1.id.uuidString }
result.append(contentsOf: orphans)
return result
}
private func resolveTerminalPanel(from arg: String, tabManager: TabManager) -> TerminalPanel? {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return nil
}
if let uuid = UUID(uuidString: arg) {
return tab.terminalPanel(for: uuid)
}
if let index = Int(arg), index >= 0 {
let panels = orderedPanels(in: tab)
guard index < panels.count else { return nil }
return panels[index] as? TerminalPanel
}
return nil
}
private func resolveTerminalSurface(from arg: String, tabManager: TabManager, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? {
guard let terminalPanel = resolveTerminalPanel(from: arg, tabManager: tabManager) else { return nil }
return waitForTerminalSurface(terminalPanel, waitUpTo: timeout)
}
private func waitForTerminalSurface(_ terminalPanel: TerminalPanel, waitUpTo timeout: TimeInterval = 0.6) -> ghostty_surface_t? {
if let surface = terminalPanel.surface.surface { return surface }
// This can be transient during bonsplit tree restructuring when the SwiftUI
// view is temporarily detached and then reattached (surface creation is
// gated on view/window/bounds). Pump the runloop briefly to allow pending
// attach retries to execute.
let deadline = Date().addingTimeInterval(timeout)
while terminalPanel.surface.surface == nil && Date() < deadline {
RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
return terminalPanel.surface.surface
}
private func resolveSurface(from arg: String, tabManager: TabManager) -> ghostty_surface_t? {
// Backwards compatibility: resolve a terminal surface by panel UUID or a stable index.
// Use a slightly longer wait to reduce flakiness during bonsplit/layout restructures.
return resolveTerminalSurface(from: arg, tabManager: tabManager, waitUpTo: 2.0)
}
private func resolveSurfaceId(from arg: String, tab: Workspace) -> UUID? {
if let uuid = UUID(uuidString: arg), tab.panels[uuid] != nil {
return uuid
}
if let index = Int(arg), index >= 0 {
let panels = orderedPanels(in: tab)
guard index < panels.count else { return nil }
return panels[index].id
}
return nil
}
private func parseNotificationPayload(_ args: String) -> (String, String, String) {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return ("Notification", "", "") }
let parts = trimmed.split(separator: "|", maxSplits: 2, omittingEmptySubsequences: false).map(String.init)
let title = parts.count > 0 ? parts[0].trimmingCharacters(in: .whitespacesAndNewlines) : ""
let subtitle = parts.count > 2 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : ""
let body = parts.count > 2
? parts[2].trimmingCharacters(in: .whitespacesAndNewlines)
: (parts.count > 1 ? parts[1].trimmingCharacters(in: .whitespacesAndNewlines) : "")
return (title.isEmpty ? "Notification" : title, subtitle, body)
}
private func closeWorkspace(_ tabId: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
guard let uuid = UUID(uuidString: tabId) else { return "ERROR: Invalid tab ID" }
var success = false
DispatchQueue.main.sync {
if let tab = tabManager.tabs.first(where: { $0.id == uuid }) {
tabManager.closeTab(tab)
success = true
}
}
return success ? "OK" : "ERROR: Tab not found"
}
private func selectWorkspace(_ arg: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var success = false
DispatchQueue.main.sync {
// Try as UUID first
if let uuid = UUID(uuidString: arg) {
if let tab = tabManager.tabs.first(where: { $0.id == uuid }) {
tabManager.selectTab(tab)
success = true
}
}
// Try as index
else if let index = Int(arg), index >= 0, index < tabManager.tabs.count {
tabManager.selectTab(at: index)
success = true
}
}
return success ? "OK" : "ERROR: Tab not found"
}
private func currentWorkspace() -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result: String = ""
DispatchQueue.main.sync {
if let id = tabManager.selectedTabId {
result = id.uuidString
}
}
return result.isEmpty ? "ERROR: No tab selected" : result
}
private func sendKeyEvent(
surface: ghostty_surface_t,
keycode: UInt32,
mods: ghostty_input_mods_e = GHOSTTY_MODS_NONE,
text: String? = nil
) {
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS
keyEvent.keycode = keycode
keyEvent.mods = mods
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.unshifted_codepoint = 0
keyEvent.composing = false
if let text {
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
}
} else {
keyEvent.text = nil
_ = ghostty_surface_key(surface, keyEvent)
}
}
private func sendTextEvent(surface: ghostty_surface_t, text: String) {
sendKeyEvent(surface: surface, keycode: 0, text: text)
}
enum SocketTextChunk: Equatable {
case text(String)
case control(UnicodeScalar)
}
nonisolated static func socketTextChunks(_ text: String) -> [SocketTextChunk] {
guard !text.isEmpty else { return [] }
var chunks: [SocketTextChunk] = []
chunks.reserveCapacity(8)
var bufferedText = ""
bufferedText.reserveCapacity(text.count)
func flushBufferedText() {
guard !bufferedText.isEmpty else { return }
chunks.append(.text(bufferedText))
bufferedText.removeAll(keepingCapacity: true)
}
for scalar in text.unicodeScalars {
if isSocketControlScalar(scalar) {
flushBufferedText()
chunks.append(.control(scalar))
} else {
bufferedText.unicodeScalars.append(scalar)
}
}
flushBufferedText()
return chunks
}
private nonisolated static func isSocketControlScalar(_ scalar: UnicodeScalar) -> Bool {
switch scalar.value {
case 0x0A, 0x0D, 0x09, 0x1B, 0x7F:
return true
default:
return false
}
}
private func sendSocketText(_ text: String, surface: ghostty_surface_t) {
let chunks = Self.socketTextChunks(text)
#if DEBUG
let startedAt = ProcessInfo.processInfo.systemUptime
#endif
for chunk in chunks {
switch chunk {
case .text(let value):
sendTextEvent(surface: surface, text: value)
case .control(let scalar):
_ = handleControlScalar(scalar, surface: surface)
}
}
#if DEBUG
let elapsedMs = (ProcessInfo.processInfo.systemUptime - startedAt) * 1000.0
if elapsedMs >= 8 || chunks.count > 1 {
dlog(
"socket.send_text.inject chars=\(text.count) chunks=\(chunks.count) ms=\(String(format: "%.2f", elapsedMs))"
)
}
#endif
}
private func handleControlScalar(_ scalar: UnicodeScalar, surface: ghostty_surface_t) -> Bool {
switch scalar.value {
case 0x0A, 0x0D:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
return true
case 0x09:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
return true
case 0x1B:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
return true
case 0x7F:
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
return true
default:
return false
}
}
private func keycodeForLetter(_ letter: Character) -> UInt32? {
switch String(letter).lowercased() {
case "a": return UInt32(kVK_ANSI_A)
case "b": return UInt32(kVK_ANSI_B)
case "c": return UInt32(kVK_ANSI_C)
case "d": return UInt32(kVK_ANSI_D)
case "e": return UInt32(kVK_ANSI_E)
case "f": return UInt32(kVK_ANSI_F)
case "g": return UInt32(kVK_ANSI_G)
case "h": return UInt32(kVK_ANSI_H)
case "i": return UInt32(kVK_ANSI_I)
case "j": return UInt32(kVK_ANSI_J)
case "k": return UInt32(kVK_ANSI_K)
case "l": return UInt32(kVK_ANSI_L)
case "m": return UInt32(kVK_ANSI_M)
case "n": return UInt32(kVK_ANSI_N)
case "o": return UInt32(kVK_ANSI_O)
case "p": return UInt32(kVK_ANSI_P)
case "q": return UInt32(kVK_ANSI_Q)
case "r": return UInt32(kVK_ANSI_R)
case "s": return UInt32(kVK_ANSI_S)
case "t": return UInt32(kVK_ANSI_T)
case "u": return UInt32(kVK_ANSI_U)
case "v": return UInt32(kVK_ANSI_V)
case "w": return UInt32(kVK_ANSI_W)
case "x": return UInt32(kVK_ANSI_X)
case "y": return UInt32(kVK_ANSI_Y)
case "z": return UInt32(kVK_ANSI_Z)
default: return nil
}
}
private func sendNamedKey(_ surface: ghostty_surface_t, keyName: String) -> Bool {
switch keyName.lowercased() {
case "ctrl-c", "ctrl+c", "sigint":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_C), mods: GHOSTTY_MODS_CTRL)
return true
case "ctrl-d", "ctrl+d", "eof":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_D), mods: GHOSTTY_MODS_CTRL)
return true
case "ctrl-z", "ctrl+z", "sigtstp":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Z), mods: GHOSTTY_MODS_CTRL)
return true
case "ctrl-\\", "ctrl+\\", "sigquit":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_ANSI_Backslash), mods: GHOSTTY_MODS_CTRL)
return true
case "enter", "return":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Return))
return true
case "tab":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Tab))
return true
case "escape", "esc":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Escape))
return true
case "backspace":
sendKeyEvent(surface: surface, keycode: UInt32(kVK_Delete))
return true
default:
if keyName.lowercased().hasPrefix("ctrl-") || keyName.lowercased().hasPrefix("ctrl+") {
let letter = keyName.dropFirst(5)
if letter.count == 1, let char = letter.first, let keycode = keycodeForLetter(char) {
sendKeyEvent(surface: surface, keycode: keycode, mods: GHOSTTY_MODS_CTRL)
return true
}
}
return false
}
}
private func sendInput(_ text: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var success = false
var error: String?
DispatchQueue.main.sync {
guard let selectedId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == selectedId }),
let terminalPanel = tab.focusedTerminalPanel else {
error = "ERROR: No focused terminal"
return
}
// Unescape common escape sequences
// Note: \n is converted to \r for terminal (Enter key sends \r)
let unescaped = text
.replacingOccurrences(of: "\\n", with: "\r")
.replacingOccurrences(of: "\\r", with: "\r")
.replacingOccurrences(of: "\\t", with: "\t")
if let surface = terminalPanel.surface.surface {
sendSocketText(unescaped, surface: surface)
} else {
terminalPanel.sendText(unescaped)
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
success = true
}
if let error { return error }
return success ? "OK" : "ERROR: Failed to send input"
}
private func sendInputToSurface(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
guard parts.count == 2 else { return "ERROR: Usage: send_surface <id|idx> <text>" }
let target = parts[0]
let text = parts[1]
var success = false
DispatchQueue.main.sync {
guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else { return }
let unescaped = text
.replacingOccurrences(of: "\\n", with: "\r")
.replacingOccurrences(of: "\\r", with: "\r")
.replacingOccurrences(of: "\\t", with: "\t")
if let surface = terminalPanel.surface.surface {
sendSocketText(unescaped, surface: surface)
} else {
terminalPanel.sendText(unescaped)
terminalPanel.surface.requestBackgroundSurfaceStartIfNeeded()
}
success = true
}
return success ? "OK" : "ERROR: Failed to send input"
}
private func sendKey(_ keyName: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var success = false
var error: String?
DispatchQueue.main.sync {
guard let selectedId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == selectedId }),
let terminalPanel = tab.focusedTerminalPanel else {
error = "ERROR: No focused terminal"
return
}
guard let surface = terminalPanel.surface.surface else {
error = "ERROR: Surface not ready"
return
}
success = sendNamedKey(surface, keyName: keyName)
}
if let error { return error }
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
}
private func sendKeyToSurface(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
guard parts.count == 2 else { return "ERROR: Usage: send_key_surface <id|idx> <key>" }
let target = parts[0]
let keyName = parts[1]
var success = false
var error: String?
DispatchQueue.main.sync {
guard let terminalPanel = resolveTerminalPanel(from: target, tabManager: tabManager) else {
error = "ERROR: Surface not found"
return
}
guard let surface = terminalPanel.surface.surface else {
error = "ERROR: Surface not ready"
return
}
success = sendNamedKey(surface, keyName: keyName)
}
if let error { return error }
return success ? "OK" : "ERROR: Unknown key '\(keyName)'"
}
// MARK: - Browser Panel Commands
private func openBrowser(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let url: URL? = trimmed.isEmpty ? nil : URL(string: trimmed)
let shouldFocus = socketCommandAllowsInAppFocusMutations()
var result = "ERROR: Failed to create browser panel"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let focusedPanelId = tab.focusedPanelId else {
return
}
if let browserPanelId = tab.newBrowserSplit(
from: focusedPanelId,
orientation: .horizontal,
url: url,
focus: shouldFocus
)?.id {
result = "OK \(browserPanelId.uuidString)"
}
}
return result
}
private func navigateBrowser(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let parts = args.split(separator: " ", maxSplits: 1).map(String.init)
guard parts.count == 2 else { return "ERROR: Usage: navigate <panel_id> <url>" }
let panelArg = parts[0]
let urlStr = parts[1]
var result = "ERROR: Panel not found or not a browser"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = UUID(uuidString: panelArg),
let browserPanel = tab.browserPanel(for: panelId) else {
return
}
browserPanel.navigateSmart(urlStr)
result = "OK"
}
return result
}
private func browserBack(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: browser_back <panel_id>" }
var result = "ERROR: Panel not found or not a browser"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = UUID(uuidString: panelArg),
let browserPanel = tab.browserPanel(for: panelId) else {
return
}
browserPanel.goBack()
result = "OK"
}
return result
}
private func browserForward(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: browser_forward <panel_id>" }
var result = "ERROR: Panel not found or not a browser"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = UUID(uuidString: panelArg),
let browserPanel = tab.browserPanel(for: panelId) else {
return
}
browserPanel.goForward()
result = "OK"
}
return result
}
private func browserReload(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: browser_reload <panel_id>" }
var result = "ERROR: Panel not found or not a browser"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = UUID(uuidString: panelArg),
let browserPanel = tab.browserPanel(for: panelId) else {
return
}
browserPanel.reload()
result = "OK"
}
return result
}
private func getUrl(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: get_url <panel_id>" }
var result = "ERROR: Panel not found or not a browser"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = UUID(uuidString: panelArg),
let browserPanel = tab.browserPanel(for: panelId) else {
return
}
result = browserPanel.currentURL?.absoluteString ?? ""
}
return result
}
private func focusWebView(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: focus_webview <panel_id>" }
var result = "ERROR: Panel not found or not a browser"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = UUID(uuidString: panelArg),
let browserPanel = tab.browserPanel(for: panelId) else {
return
}
// Programmatic WebView focus should win over stale omnibar focus state, especially
// after workspace switches where the blank-page omnibar auto-focus can re-trigger.
browserPanel.endSuppressWebViewFocusForAddressBar()
browserPanel.clearWebViewFocusSuppression()
NotificationCenter.default.post(name: .browserDidBlurAddressBar, object: panelId)
// Prevent omnibar auto-focus from immediately stealing first responder back.
browserPanel.suppressOmnibarAutofocus(for: 1.5)
let webView = browserPanel.webView
guard let window = webView.window else {
result = "ERROR: WebView is not in a window"
return
}
guard !webView.isHiddenOrHasHiddenAncestor else {
result = "ERROR: WebView is hidden"
return
}
window.makeFirstResponder(webView)
if Self.responderChainContains(window.firstResponder, target: webView) {
// Some focus churn paths (workspace handoff / omnibar blur) can race this call.
// Reassert on the next runloop if another responder steals focus immediately.
DispatchQueue.main.async { [weak window, weak webView] in
guard let window, let webView else { return }
guard webView.window === window else { return }
if !Self.responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(webView)
}
}
result = "OK"
} else {
result = "ERROR: Focus did not move into web view"
}
}
return result
}
private func isWebViewFocused(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !panelArg.isEmpty else { return "ERROR: Usage: is_webview_focused <panel_id>" }
var result = "ERROR: Panel not found or not a browser"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let panelId = UUID(uuidString: panelArg),
let browserPanel = tab.browserPanel(for: panelId) else {
return
}
let webView = browserPanel.webView
guard let window = webView.window else {
result = "false"
return
}
result = Self.responderChainContains(window.firstResponder, target: webView) ? "true" : "false"
}
return result
}
// MARK: - Bonsplit Pane Commands
private func listPanes() -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result = ""
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
result = "ERROR: No tab selected"
return
}
let paneIds = tab.bonsplitController.allPaneIds
let focusedPaneId = tab.bonsplitController.focusedPaneId
let lines = paneIds.enumerated().map { index, paneId in
let selected = paneId == focusedPaneId ? "*" : " "
let tabCount = tab.bonsplitController.tabs(inPane: paneId).count
return "\(selected) \(index): \(paneId) [\(tabCount) tabs]"
}
result = lines.isEmpty ? "No panes" : lines.joined(separator: "\n")
}
return result
}
private func listPaneSurfaces(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result = ""
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
result = "ERROR: No tab selected"
return
}
// Parse --pane=<pane-id|index> argument (UUID preferred).
var paneArg: String?
for part in args.split(separator: " ") {
if part.hasPrefix("--pane=") {
paneArg = String(part.dropFirst(7))
break
}
}
let paneIds = tab.bonsplitController.allPaneIds
var targetPaneId: PaneID? = tab.bonsplitController.focusedPaneId
if let paneArg {
if let uuid = UUID(uuidString: paneArg),
let paneId = paneIds.first(where: { $0.id == uuid }) {
targetPaneId = paneId
} else if let index = Int(paneArg), index >= 0, index < paneIds.count {
targetPaneId = paneIds[index]
} else {
result = "ERROR: Pane not found"
return
}
}
guard let paneId = targetPaneId else {
result = "ERROR: No pane to list tabs from"
return
}
let tabs = tab.bonsplitController.tabs(inPane: paneId)
let selectedTab = tab.bonsplitController.selectedTab(inPane: paneId)
let lines = tabs.enumerated().map { index, bonsplitTab in
let selected = bonsplitTab.id == selectedTab?.id ? "*" : " "
let panelId = tab.panelIdFromSurfaceId(bonsplitTab.id)
let panelIdStr = panelId?.uuidString ?? "unknown"
return "\(selected) \(index): \(bonsplitTab.title) [panel:\(panelIdStr)]"
}
result = lines.isEmpty ? "No tabs in pane" : lines.joined(separator: "\n")
}
return result
}
private func focusPane(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let paneArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !paneArg.isEmpty else { return "ERROR: Usage: focus_pane <pane_id>" }
var result = "ERROR: Pane not found"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
let paneIds = tab.bonsplitController.allPaneIds
// Try UUID first, then fall back to index
if let uuid = UUID(uuidString: paneArg),
let paneId = paneIds.first(where: { $0.id == uuid }) {
tab.bonsplitController.focusPane(paneId)
result = "OK"
} else if let index = Int(paneArg), index >= 0, index < paneIds.count {
tab.bonsplitController.focusPane(paneIds[index])
result = "OK"
}
}
return result
}
private func focusSurfaceByPanel(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let tabArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !tabArg.isEmpty else { return "ERROR: Usage: focus_surface_by_panel <panel_id>" }
var result = "ERROR: Panel not found"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
// Focus by panel UUID (our stable surface handle). This must also move AppKit
// first responder into the terminal view to ensure typing routes correctly.
if let panelUUID = UUID(uuidString: tabArg),
tab.panels[panelUUID] != nil,
tab.surfaceIdFromPanelId(panelUUID) != nil {
tabManager.focusSurface(tabId: tab.id, surfaceId: panelUUID)
result = "OK"
}
}
return result
}
private func dragSurfaceToSplit(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
let parts = trimmed.split(separator: " ").map(String.init)
guard parts.count >= 2 else { return "ERROR: Usage: drag_surface_to_split <id|idx> <left|right|up|down>" }
let surfaceArg = parts[0]
let directionArg = parts[1]
guard let direction = parseSplitDirection(directionArg) else {
return "ERROR: Invalid direction. Use left, right, up, or down."
}
let orientation: SplitOrientation = direction.isHorizontal ? .horizontal : .vertical
let insertFirst = (direction == .left || direction == .up)
var result = "ERROR: Failed to move surface"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
result = "ERROR: No tab selected"
return
}
guard let panelId = resolveSurfaceId(from: surfaceArg, tab: tab),
let bonsplitTabId = tab.surfaceIdFromPanelId(panelId) else {
result = "ERROR: Surface not found"
return
}
guard let newPaneId = tab.bonsplitController.splitPane(
orientation: orientation,
movingTab: bonsplitTabId,
insertFirst: insertFirst
) else {
result = "ERROR: Failed to split pane"
return
}
result = "OK \(newPaneId.id.uuidString)"
}
return result
}
private func newPane(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
// Parse arguments: --type=terminal|browser --direction=left|right|up|down --url=...
var panelType: PanelType = .terminal
var direction: SplitDirection = .right
var url: URL? = nil
var invalidDirection = false
let parts = args.split(separator: " ")
for part in parts {
let partStr = String(part)
if partStr.hasPrefix("--type=") {
let typeStr = String(partStr.dropFirst(7))
panelType = typeStr == "browser" ? .browser : .terminal
} else if partStr.hasPrefix("--direction=") {
let dirStr = String(partStr.dropFirst(12))
if let parsed = parseSplitDirection(dirStr) {
direction = parsed
} else {
invalidDirection = true
}
} else if partStr.hasPrefix("--url=") {
let urlStr = String(partStr.dropFirst(6))
url = URL(string: urlStr)
}
}
if invalidDirection {
return "ERROR: Invalid direction. Use left, right, up, or down."
}
let orientation = direction.orientation
let insertFirst = direction.insertFirst
let shouldFocus = socketCommandAllowsInAppFocusMutations()
var result = "ERROR: Failed to create pane"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }),
let focusedPanelId = tab.focusedPanelId else {
return
}
let newPanelId: UUID?
if panelType == .browser {
newPanelId = tab.newBrowserSplit(
from: focusedPanelId,
orientation: orientation,
insertFirst: insertFirst,
url: url,
focus: shouldFocus
)?.id
} else {
newPanelId = tab.newTerminalSplit(
from: focusedPanelId,
orientation: orientation,
insertFirst: insertFirst,
focus: shouldFocus
)?.id
}
if let id = newPanelId {
result = "OK \(id.uuidString)"
}
}
return result
}
// MARK: - Option Parsing (sidebar metadata commands)
private func tokenizeArgs(_ args: String) -> [String] {
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
var tokens: [String] = []
var current = ""
var inQuote = false
var quoteChar: Character = "\""
var cursor = trimmed.startIndex
while cursor < trimmed.endIndex {
let char = trimmed[cursor]
if inQuote {
if char == quoteChar {
inQuote = false
cursor = trimmed.index(after: cursor)
continue
}
if char == "\\" {
let nextIndex = trimmed.index(after: cursor)
if nextIndex < trimmed.endIndex {
let next = trimmed[nextIndex]
switch next {
case "n":
current.append("\n")
cursor = trimmed.index(after: nextIndex)
continue
case "r":
current.append("\r")
cursor = trimmed.index(after: nextIndex)
continue
case "t":
current.append("\t")
cursor = trimmed.index(after: nextIndex)
continue
case "\"", "'", "\\":
current.append(next)
cursor = trimmed.index(after: nextIndex)
continue
default:
break
}
}
}
current.append(char)
cursor = trimmed.index(after: cursor)
continue
}
if char == "'" || char == "\"" {
inQuote = true
quoteChar = char
cursor = trimmed.index(after: cursor)
continue
}
if char.isWhitespace {
if !current.isEmpty {
tokens.append(current)
current = ""
}
cursor = trimmed.index(after: cursor)
continue
}
current.append(char)
cursor = trimmed.index(after: cursor)
}
if !current.isEmpty {
tokens.append(current)
}
return tokens
}
private func parseOptions(_ args: String) -> (positional: [String], options: [String: String]) {
let tokens = tokenizeArgs(args)
guard !tokens.isEmpty else { return ([], [:]) }
var positional: [String] = []
var options: [String: String] = [:]
var stopParsingOptions = false
var i = 0
while i < tokens.count {
let token = tokens[i]
if stopParsingOptions {
positional.append(token)
} else if token == "--" {
stopParsingOptions = true
} else if token.hasPrefix("--") {
if let eqIndex = token.firstIndex(of: "=") {
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
let value = String(token[token.index(after: eqIndex)...])
options[key] = value
} else {
let key = String(token.dropFirst(2))
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
options[key] = tokens[i + 1]
i += 1
} else {
options[key] = ""
}
}
} else {
positional.append(token)
}
i += 1
}
return (positional, options)
}
private func parseOptionsNoStop(_ args: String) -> (positional: [String], options: [String: String]) {
let tokens = tokenizeArgs(args)
guard !tokens.isEmpty else { return ([], [:]) }
var positional: [String] = []
var options: [String: String] = [:]
var i = 0
while i < tokens.count {
let token = tokens[i]
if token == "--" {
i += 1
continue
}
if token.hasPrefix("--") {
if let eqIndex = token.firstIndex(of: "=") {
let key = String(token[token.index(token.startIndex, offsetBy: 2)..<eqIndex])
let value = String(token[token.index(after: eqIndex)...])
options[key] = value
} else {
let key = String(token.dropFirst(2))
if i + 1 < tokens.count && !tokens[i + 1].hasPrefix("--") {
options[key] = tokens[i + 1]
i += 1
} else {
options[key] = ""
}
}
} else {
positional.append(token)
}
i += 1
}
return (positional, options)
}
private func resolveTabForReport(_ args: String) -> Tab? {
guard let tabManager else { return nil }
let parsed = parseOptions(args)
if let tabArg = parsed.options["tab"], !tabArg.isEmpty {
if let tab = resolveTab(from: tabArg, tabManager: tabManager) {
return tab
}
// The tab may belong to a different window search all contexts.
if let uuid = UUID(uuidString: tabArg.trimmingCharacters(in: .whitespacesAndNewlines)),
let otherManager = AppDelegate.shared?.tabManagerFor(tabId: uuid) {
return otherManager.tabs.first(where: { $0.id == uuid })
}
return nil
}
guard let selectedId = tabManager.selectedTabId else { return nil }
return tabManager.tabs.first(where: { $0.id == selectedId })
}
private func resolveTabIdForSidebarMutation(
reportArgs: String,
options: [String: String]
) -> (tabId: UUID?, error: String?) {
var tabId: UUID?
DispatchQueue.main.sync {
if let tab = resolveTabForReport(reportArgs) {
tabId = tab.id
}
}
if let tabId {
return (tabId, nil)
}
let error = options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return (nil, error)
}
private func tabForSidebarMutation(id: UUID) -> Tab? {
if let tab = tabManager?.tabs.first(where: { $0.id == id }) {
return tab
}
if let otherManager = AppDelegate.shared?.tabManagerFor(tabId: id) {
return otherManager.tabs.first(where: { $0.id == id })
}
return nil
}
private func parseSidebarMetadataFormat(_ raw: String) -> SidebarMetadataFormat? {
switch raw.lowercased() {
case "plain":
return .plain
case "markdown", "md":
return .markdown
default:
return nil
}
}
private func normalizedOptionValue(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private func upsertSidebarMetadata(_ args: String, missingError: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptionsNoStop(args)
guard parsed.positional.count >= 2 else { return missingError }
let key = parsed.positional[0]
let value = parsed.positional[1...].joined(separator: " ")
let icon = normalizedOptionValue(parsed.options["icon"])
let color = normalizedOptionValue(parsed.options["color"])
let formatRaw = normalizedOptionValue(parsed.options["format"]) ?? SidebarMetadataFormat.plain.rawValue
guard let format = parseSidebarMetadataFormat(formatRaw) else {
return "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown"
}
let priority: Int
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
guard let parsedPriority = Int(rawPriority) else {
return "ERROR: Invalid metadata priority '\(rawPriority)' — must be an integer"
}
priority = max(-9999, min(9999, parsedPriority))
} else {
priority = 0
}
let parsedURL: URL?
if let rawURL = normalizedOptionValue(parsed.options["url"] ?? parsed.options["link"]) {
guard let candidate = URL(string: rawURL),
let scheme = candidate.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return "ERROR: Invalid metadata URL '\(rawURL)' — expected http(s) URL"
}
parsedURL = candidate
} else {
parsedURL = nil
}
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
guard let targetTabId = tabResolution.tabId else {
return tabResolution.error ?? "ERROR: No tab selected"
}
DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
guard Self.shouldReplaceStatusEntry(
current: tab.statusEntries[key],
key: key,
value: value,
icon: icon,
color: color,
url: parsedURL,
priority: priority,
format: format
) else {
return
}
tab.statusEntries[key] = SidebarStatusEntry(
key: key,
value: value,
icon: icon,
color: color,
url: parsedURL,
priority: priority,
format: format,
timestamp: Date()
)
}
return "OK"
}
private func clearSidebarMetadata(_ args: String, usage: String) -> String {
let parsed = parseOptions(args)
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
return "ERROR: Missing metadata key — usage: \(usage)"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
if tab.statusEntries.removeValue(forKey: key) == nil {
result = "OK (key not found)"
}
}
return result
}
private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String {
var line = "\(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" }
if let color = entry.color { line += " color=\(color)" }
if let url = entry.url { line += " url=\(url.absoluteString)" }
if entry.priority != 0 { line += " priority=\(entry.priority)" }
if entry.format != .plain { line += " format=\(entry.format.rawValue)" }
return line
}
private func listSidebarMetadata(_ args: String, emptyMessage: String) -> String {
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
let entries = tab.sidebarStatusEntriesInDisplayOrder()
if entries.isEmpty {
result = emptyMessage
return
}
result = entries.map(sidebarMetadataLine).joined(separator: "\n")
}
return result
}
private func setStatus(_ args: String) -> String {
upsertSidebarMetadata(
args,
missingError: "ERROR: Missing status key or value — usage: set_status <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
)
}
private func reportMeta(_ args: String) -> String {
upsertSidebarMetadata(
args,
missingError: "ERROR: Missing metadata key or value — usage: report_meta <key> <value> [--icon=X] [--color=#hex] [--url=X] [--priority=N] [--format=plain|markdown] [--tab=X]"
)
}
private func clearStatus(_ args: String) -> String {
clearSidebarMetadata(args, usage: "clear_status <key> [--tab=X]")
}
private func clearMeta(_ args: String) -> String {
clearSidebarMetadata(args, usage: "clear_meta <key> [--tab=X]")
}
private func listStatus(_ args: String) -> String {
listSidebarMetadata(args, emptyMessage: "No status entries")
}
private func listMeta(_ args: String) -> String {
listSidebarMetadata(args, emptyMessage: "No metadata entries")
}
private func splitMetadataBlockArgs(_ args: String) -> (optionsPart: String, markdownPart: String?) {
guard let separatorRange = args.range(of: " -- ") else {
return (args, nil)
}
let optionsPart = String(args[..<separatorRange.lowerBound])
let markdownPart = String(args[separatorRange.upperBound...])
return (optionsPart, markdownPart)
}
private func sidebarMetadataBlockLine(_ block: SidebarMetadataBlock) -> String {
var line = "\(block.key)=\(block.markdown.replacingOccurrences(of: "\n", with: "\\n"))"
if block.priority != 0 { line += " priority=\(block.priority)" }
return line
}
private func reportMetaBlock(_ args: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parts = splitMetadataBlockArgs(args)
let parsed = parseOptionsNoStop(parts.optionsPart)
guard let key = parsed.positional.first, !key.isEmpty else {
return "ERROR: Missing metadata block key — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
}
let markdown: String
if let raw = parts.markdownPart {
markdown = raw
} else if parsed.positional.count >= 2 {
markdown = parsed.positional.dropFirst().joined(separator: " ")
} else {
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
}
let normalizedMarkdown = markdown
.replacingOccurrences(of: "\\r\\n", with: "\n")
.replacingOccurrences(of: "\\n", with: "\n")
.replacingOccurrences(of: "\\t", with: "\t")
let trimmedMarkdown = normalizedMarkdown.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedMarkdown.isEmpty else {
return "ERROR: Missing metadata markdown — usage: report_meta_block <key> [--priority=N] [--tab=X] -- <markdown>"
}
let priority: Int
if let rawPriority = normalizedOptionValue(parsed.options["priority"]) {
guard let parsedPriority = Int(rawPriority) else {
return "ERROR: Invalid metadata block priority '\(rawPriority)' — must be an integer"
}
priority = max(-9999, min(9999, parsedPriority))
} else {
priority = 0
}
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: parts.optionsPart, options: parsed.options)
guard let targetTabId = tabResolution.tabId else {
return tabResolution.error ?? "ERROR: No tab selected"
}
DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
guard Self.shouldReplaceMetadataBlock(
current: tab.metadataBlocks[key],
key: key,
markdown: normalizedMarkdown,
priority: priority
) else {
return
}
tab.metadataBlocks[key] = SidebarMetadataBlock(
key: key,
markdown: normalizedMarkdown,
priority: priority,
timestamp: Date()
)
}
return "OK"
}
private func clearMetaBlock(_ args: String) -> String {
let parsed = parseOptions(args)
guard let key = parsed.positional.first, parsed.positional.count == 1 else {
return "ERROR: Missing metadata block key — usage: clear_meta_block <key> [--tab=X]"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
if tab.metadataBlocks.removeValue(forKey: key) == nil {
result = "OK (key not found)"
}
}
return result
}
private func listMetaBlocks(_ args: String) -> String {
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
let blocks = tab.sidebarMetadataBlocksInDisplayOrder()
if blocks.isEmpty {
result = "No metadata blocks"
return
}
result = blocks.map(sidebarMetadataBlockLine).joined(separator: "\n")
}
return result
}
private func appendLog(_ args: String) -> String {
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
return "ERROR: Missing message — usage: log [--level=X] [--source=X] [--tab=X] -- <message>"
}
let message = parsed.positional.joined(separator: " ")
let levelStr = parsed.options["level"] ?? "info"
guard let level = SidebarLogLevel(rawValue: levelStr) else {
return "ERROR: Unknown log level '\(levelStr)' — use: info, progress, success, warning, error"
}
let source = parsed.options["source"]
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
tab.logEntries.append(SidebarLogEntry(message: message, level: level, source: source, timestamp: Date()))
let configuredLimit = UserDefaults.standard.object(forKey: "sidebarMaxLogEntries") as? Int ?? 50
let limit = max(1, min(500, configuredLimit))
if tab.logEntries.count > limit {
tab.logEntries.removeFirst(tab.logEntries.count - limit)
}
}
return result
}
private func clearLog(_ args: String) -> String {
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
tab.logEntries.removeAll()
}
return result
}
private func listLog(_ args: String) -> String {
let parsed = parseOptions(args)
var limit: Int?
if let limitStr = parsed.options["limit"] {
if limitStr.isEmpty {
return "ERROR: Missing limit value — usage: list_log [--limit=N] [--tab=X]"
}
guard let parsedLimit = Int(limitStr), parsedLimit >= 0 else {
return "ERROR: Invalid limit '\(limitStr)' — must be >= 0"
}
limit = parsedLimit
}
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
if tab.logEntries.isEmpty {
result = "No log entries"
return
}
let entries: [SidebarLogEntry]
if let limit {
entries = Array(tab.logEntries.suffix(limit))
} else {
entries = tab.logEntries
}
result = entries.map { entry in
var line = "[\(entry.level.rawValue)] \(entry.message)"
if let source = entry.source, !source.isEmpty {
line = "[\(source)] \(line)"
}
return line
}.joined(separator: "\n")
}
return result
}
private func setProgress(_ args: String) -> String {
let parsed = parseOptions(args)
guard let first = parsed.positional.first else {
return "ERROR: Missing progress value — usage: set_progress <0.0-1.0> [--label=X] [--tab=X]"
}
guard let value = Double(first), value.isFinite else {
return "ERROR: Invalid progress value '\(first)' — must be 0.0 to 1.0"
}
let clamped = min(1.0, max(0.0, value))
let label = parsed.options["label"]
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
guard Self.shouldReplaceProgress(current: tab.progress, value: clamped, label: label) else {
return
}
tab.progress = SidebarProgressState(value: clamped, label: label)
}
return result
}
private func clearProgress(_ args: String) -> String {
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
if tab.progress != nil {
tab.progress = nil
}
}
return result
}
private func reportGitBranch(_ args: String) -> String {
let parsed = parseOptions(args)
guard let branch = parsed.positional.first else {
return "ERROR: Missing branch name — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]"
}
let isDirty = parsed.options["status"]?.lowercased() == "dirty"
// Shell integration always includes explicit workspace/panel IDs.
// Keep this telemetry path off-main so wake/main-thread stalls don't
// block socket handlers and starve subsequent branch updates.
if let scope = Self.explicitSocketScope(options: parsed.options) {
DispatchQueue.main.async {
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId),
let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else {
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard validSurfaceIds.contains(scope.panelId) else { return }
tab.updatePanelGitBranch(panelId: scope.panelId, branch: branch, isDirty: isDirty)
}
return "OK"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_git_branch <branch> [--status=dirty] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.updatePanelGitBranch(panelId: surfaceId, branch: branch, isDirty: isDirty)
}
return result
}
private func clearGitBranch(_ args: String) -> String {
let parsed = parseOptions(args)
// Shell integration always includes explicit workspace/panel IDs.
// Keep this telemetry path off-main so wake/main-thread stalls don't
// block socket handlers and starve subsequent branch updates.
if let scope = Self.explicitSocketScope(options: parsed.options) {
DispatchQueue.main.async {
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId),
let tab = tabManager.tabs.first(where: { $0.id == scope.workspaceId }) else {
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard validSurfaceIds.contains(scope.panelId) else { return }
tab.clearPanelGitBranch(panelId: scope.panelId)
}
return "OK"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_git_branch [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.clearPanelGitBranch(panelId: surfaceId)
}
return result
}
private func reportPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
guard parsed.positional.count >= 2 else {
return "ERROR: Missing pull request number or URL — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
}
let rawNumber = parsed.positional[0].trimmingCharacters(in: .whitespacesAndNewlines)
let numberToken = rawNumber.hasPrefix("#") ? String(rawNumber.dropFirst()) : rawNumber
guard let number = Int(numberToken), number > 0 else {
return "ERROR: Invalid pull request number '\(rawNumber)'"
}
let rawURL = parsed.positional[1].trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: rawURL),
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return "ERROR: Invalid pull request URL '\(rawURL)'"
}
let statusRaw = (parsed.options["state"] ?? "open").lowercased()
guard let status = SidebarPullRequestStatus(rawValue: statusRaw) else {
return "ERROR: Invalid pull request state '\(statusRaw)' — use: open, merged, closed"
}
let labelRaw = normalizedOptionValue(parsed.options["label"]) ?? "PR"
guard !labelRaw.isEmpty else {
return "ERROR: Invalid review label — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
}
let label = String(labelRaw.prefix(16))
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
guard Self.shouldReplacePullRequest(
current: tab.panelPullRequests[surfaceId],
number: number,
label: label,
url: url,
status: status
) else {
return
}
tab.updatePanelPullRequest(
panelId: surfaceId,
number: number,
label: label,
url: url,
status: status
)
}
return result
}
private func clearPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tab.clearPanelPullRequest(panelId: surfaceId)
}
return result
}
private func reportPorts(_ args: String) -> String {
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
return "ERROR: Missing ports — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
}
var ports: [Int] = []
for portStr in parsed.positional {
guard let port = Int(portStr), port > 0, port <= 65535 else {
return "ERROR: Invalid port '\(portStr)' — must be 1-65535"
}
ports.append(port)
}
let normalizedPorts = Array(Set(ports)).sorted()
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_ports <port1> [port2...] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
guard Self.shouldReplacePorts(current: tab.surfaceListeningPorts[surfaceId], next: normalizedPorts) else {
return
}
tab.surfaceListeningPorts[surfaceId] = normalizedPorts
tab.recomputeListeningPorts()
}
return result
}
private func reportPwd(_ args: String) -> String {
let parsed = parseOptions(args)
guard !parsed.positional.isEmpty else {
return "ERROR: Missing path — usage: report_pwd <path> [--tab=X] [--panel=Y]"
}
let directory = Self.normalizeReportedDirectory(parsed.positional.joined(separator: " "))
// Shell integration provides explicit UUID handles for cwd updates.
// Keep this hot path off-main and drop no-op reports before scheduling UI work.
if let scope = Self.explicitSocketScope(options: parsed.options) {
guard Self.socketFastPathState.shouldPublishDirectory(
workspaceId: scope.workspaceId,
panelId: scope.panelId,
directory: directory
) else {
return "OK"
}
DispatchQueue.main.async {
guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: scope.workspaceId) else { return }
tabManager.updateSurfaceDirectory(tabId: scope.workspaceId, surfaceId: scope.panelId, directory: directory)
}
return "OK"
}
guard let tabManager else { return "ERROR: TabManager not available" }
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_pwd <path> [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
tabManager.updateSurfaceDirectory(tabId: tab.id, surfaceId: surfaceId, directory: directory)
}
return result
}
private func clearPorts(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_ports [--tab=X] [--panel=Y]"
return
}
guard let surfaceId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
if tab.surfaceListeningPorts.removeValue(forKey: surfaceId) != nil {
tab.recomputeListeningPorts()
}
} else {
if !tab.surfaceListeningPorts.isEmpty {
tab.surfaceListeningPorts.removeAll()
tab.recomputeListeningPorts()
}
}
}
return result
}
private func reportTTY(_ args: String) -> String {
let parsed = parseOptions(args)
guard let ttyName = parsed.positional.first, !ttyName.isEmpty else {
return "ERROR: Missing tty name — usage: report_tty <tty_name> [--tab=X] [--panel=Y]"
}
// Shell integration always provides explicit UUID handles.
// Handle that common path off-main to avoid sync-hopping on every report.
if let scope = Self.explicitSocketScope(options: parsed.options) {
PortScanner.shared.registerTTY(
workspaceId: scope.workspaceId,
panelId: scope.panelId,
ttyName: ttyName
)
return "OK"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_tty <tty_name> [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
let validSurfaceIds = Set(tab.panels.keys)
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
guard tab.surfaceTTYNames[surfaceId] != ttyName else { return }
tab.surfaceTTYNames[surfaceId] = ttyName
PortScanner.shared.registerTTY(workspaceId: tab.id, panelId: surfaceId, ttyName: ttyName)
}
return result
}
private func portsKick(_ args: String) -> String {
let parsed = parseOptions(args)
// Shell integration always provides explicit UUID handles.
// Handle that common path off-main to keep prompt hooks from blocking UI work.
if let scope = Self.explicitSocketScope(options: parsed.options) {
PortScanner.shared.kick(workspaceId: scope.workspaceId, panelId: scope.panelId)
return "OK"
}
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: ports_kick [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
PortScanner.shared.kick(workspaceId: tab.id, panelId: surfaceId)
}
return result
}
private func sidebarState(_ args: String) -> String {
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
var lines: [String] = []
lines.append("tab=\(tab.id.uuidString)")
lines.append("cwd=\(tab.currentDirectory)")
if let focused = tab.focusedPanelId,
let focusedDir = tab.panelDirectories[focused] {
lines.append("focused_cwd=\(focusedDir)")
lines.append("focused_panel=\(focused.uuidString)")
} else {
lines.append("focused_cwd=unknown")
lines.append("focused_panel=unknown")
}
if let git = tab.gitBranch {
lines.append("git_branch=\(git.branch)\(git.isDirty ? " dirty" : " clean")")
} else {
lines.append("git_branch=none")
}
if let pr = tab.pullRequest {
lines.append("pr=#\(pr.number) \(pr.status.rawValue) \(pr.url.absoluteString)")
lines.append("pr_label=\(pr.label)")
} else {
lines.append("pr=none")
lines.append("pr_label=none")
}
if tab.listeningPorts.isEmpty {
lines.append("ports=none")
} else {
lines.append("ports=\(tab.listeningPorts.map(String.init).joined(separator: ","))")
}
if let progress = tab.progress {
let label = progress.label ?? ""
lines.append("progress=\(String(format: "%.2f", progress.value)) \(label)".trimmingCharacters(in: .whitespaces))
} else {
lines.append("progress=none")
}
let statusEntries = tab.sidebarStatusEntriesInDisplayOrder()
lines.append("status_count=\(statusEntries.count)")
for entry in statusEntries {
lines.append(" \(sidebarMetadataLine(entry))")
}
let metadataBlocks = tab.sidebarMetadataBlocksInDisplayOrder()
lines.append("meta_block_count=\(metadataBlocks.count)")
for block in metadataBlocks {
lines.append(" \(sidebarMetadataBlockLine(block))")
}
lines.append("log_count=\(tab.logEntries.count)")
for entry in tab.logEntries.suffix(5) {
lines.append(" [\(entry.level.rawValue)] \(entry.message)")
}
result = lines.joined(separator: "\n")
}
return result
}
private func resetSidebar(_ args: String) -> String {
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = "ERROR: Tab not found"
return
}
tab.statusEntries.removeAll()
tab.logEntries.removeAll()
tab.progress = nil
tab.gitBranch = nil
tab.panelGitBranches.removeAll()
tab.pullRequest = nil
tab.panelPullRequests.removeAll()
tab.surfaceListeningPorts.removeAll()
tab.listeningPorts.removeAll()
tab.metadataBlocks.removeAll()
}
return result
}
private func refreshSurfaces() -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var refreshedCount = 0
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
// Force-refresh all terminal panels in current tab
// (resets cached metrics so the Metal layer drawable resizes correctly)
for panel in tab.panels.values {
if let terminalPanel = panel as? TerminalPanel {
terminalPanel.surface.forceRefresh()
refreshedCount += 1
}
}
}
return "OK Refreshed \(refreshedCount) surfaces"
}
private func viewDepth(of view: NSView, maxDepth: Int = 128) -> Int {
var depth = 0
var current: NSView? = view
while let v = current, depth < maxDepth {
current = v.superview
depth += 1
}
return depth
}
private func isPortalHosted(_ view: NSView) -> Bool {
var current: NSView? = view
while let v = current {
if v is WindowTerminalHostView { return true }
current = v.superview
}
return false
}
private func surfaceHealth(_ tabArg: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
var result = ""
DispatchQueue.main.sync {
guard let tab = resolveTab(from: tabArg, tabManager: tabManager) else {
result = "ERROR: Tab not found"
return
}
let panels = orderedPanels(in: tab)
let lines = panels.enumerated().map { index, panel -> String in
let panelId = panel.id.uuidString
let type = panel.panelType.rawValue
if let tp = panel as? TerminalPanel {
let inWindow = tp.surface.isViewInWindow
let portalHosted = isPortalHosted(tp.hostedView)
let depth = viewDepth(of: tp.hostedView)
return "\(index): \(panelId) type=\(type) in_window=\(inWindow) portal=\(portalHosted) view_depth=\(depth)"
} else if let bp = panel as? BrowserPanel {
let inWindow = bp.webView.window != nil
return "\(index): \(panelId) type=\(type) in_window=\(inWindow)"
} else {
return "\(index): \(panelId) type=\(type) in_window=unknown"
}
}
result = lines.isEmpty ? "No surfaces" : lines.joined(separator: "\n")
}
return result
}
private func closeSurface(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmed = args.trimmingCharacters(in: .whitespacesAndNewlines)
var result = "ERROR: Failed to close surface"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
// Resolve surface ID from argument or use focused
let surfaceId: UUID?
if trimmed.isEmpty {
surfaceId = tab.focusedPanelId
} else {
surfaceId = resolveSurfaceId(from: trimmed, tab: tab)
}
guard let targetSurfaceId = surfaceId else {
result = "ERROR: Surface not found"
return
}
// Don't close if it's the only surface
if tab.panels.count <= 1 {
result = "ERROR: Cannot close the last surface"
return
}
// Socket commands must be non-interactive: bypass close-confirmation gating.
tab.closePanel(targetSurfaceId, force: true)
result = "OK"
}
return result
}
private func newSurface(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
// Parse arguments: --type=terminal|browser --pane=<pane_id> --url=...
var panelType: PanelType = .terminal
var paneArg: String? = nil
var url: URL? = nil
let shouldFocus = socketCommandAllowsInAppFocusMutations()
let parts = args.split(separator: " ")
for part in parts {
let partStr = String(part)
if partStr.hasPrefix("--type=") {
let typeStr = String(partStr.dropFirst(7))
panelType = typeStr == "browser" ? .browser : .terminal
} else if partStr.hasPrefix("--pane=") {
paneArg = String(partStr.dropFirst(7))
} else if partStr.hasPrefix("--url=") {
let urlStr = String(partStr.dropFirst(6))
url = URL(string: urlStr)
}
}
var result = "ERROR: Failed to create tab"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
// Get target pane
let paneId: PaneID?
let paneIds = tab.bonsplitController.allPaneIds
if let paneArg {
if let uuid = UUID(uuidString: paneArg) {
paneId = paneIds.first(where: { $0.id == uuid })
} else if let idx = Int(paneArg), idx >= 0, idx < paneIds.count {
paneId = paneIds[idx]
} else {
paneId = nil
}
} else {
paneId = tab.bonsplitController.focusedPaneId
}
guard let targetPaneId = paneId else {
result = "ERROR: Pane not found"
return
}
let newPanelId: UUID?
if panelType == .browser {
newPanelId = tab.newBrowserSurface(inPane: targetPaneId, url: url, focus: shouldFocus)?.id
} else {
newPanelId = tab.newTerminalSurface(inPane: targetPaneId, focus: shouldFocus)?.id
}
if let id = newPanelId {
result = "OK \(id.uuidString)"
}
}
return result
}
deinit {
stop()
}
}