From 00da345311123d2eb23b3efff294b23a76035a5a Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 12:08:12 -0700 Subject: [PATCH 01/21] CmuxControlSocket stage 3c-1: ControlCommandCoordinator + window domain Extract the window RPC domain (window.list/current/focus/create/close/displays/ display) out of TerminalController into a new @MainActor @Observable ControlCommandCoordinator in CmuxControlSocket, behind the read-only ControlCommandContext seam (app target conforms; package never imports the app target). The coordinator owns the kind:N ControlHandleRegistry (RPC selection state per the decomposition plan); TerminalController delegates its ensureRef/ resolveRef/removeRef to it so refs stay consistent across moved and not-yet- moved domains. Faithful lift: the window bodies build ControlCallResult/JSONValue payloads whose Foundation object is identical to the legacy [String: Any] dictionaries, so the encoded wire bytes match. Dispatch runs on the main actor inside the existing withSocketCommandPolicy scope, so the per-read v2MainSync hops the legacy bodies used become plain in-isolation calls and disappear. window.current preserves both distinct legacy errors (unavailable vs not_found) via ControlCurrentWindowResolution. TerminalController.swift 22074 -> 21921 (budget ratcheted). 17 new package tests (128 total) drive every window method through a fake context, asserting byte-identical payloads, ref minting, routing-selector parsing, and the two window.current failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 2 +- .../Coordinator/ControlCommandContext.swift | 76 ++++ .../ControlCommandCoordinator+Window.swift | 154 ++++++++ .../ControlCommandCoordinator.swift | 172 +++++++++ .../ControlCurrentWindowResolution.swift | 16 + .../Coordinator/ControlDisplayInfo.swift | 56 +++ .../ControlMoveAllWindowsResult.swift | 20 ++ .../Coordinator/ControlRoutingSelectors.swift | 57 +++ .../Coordinator/ControlWindowSummary.swift | 42 +++ ...ControlCommandCoordinatorWindowTests.swift | 340 ++++++++++++++++++ Sources/TerminalController.swift | 219 +++-------- ...minalControllerControlCommandContext.swift | 86 +++++ cmux.xcodeproj/project.pbxproj | 4 + 13 files changed, 1073 insertions(+), 171 deletions(-) create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCurrentWindowResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlDisplayInfo.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMoveAllWindowsResult.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlRoutingSelectors.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowSummary.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift create mode 100644 Sources/TerminalControllerControlCommandContext.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a4abc2396b77..ad6919806ed5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,7 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 32655 CLI/cmux.swift -22074 Sources/TerminalController.swift +21952 Sources/TerminalController.swift 19820 Sources/Workspace.swift 19209 Sources/ContentView.swift 18011 Sources/AppDelegate.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift new file mode 100644 index 000000000000..20fd89d250d1 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift @@ -0,0 +1,76 @@ +public import Foundation + +/// The read-only seam through which ``ControlCommandCoordinator`` reaches live +/// app state to run control commands, without the package importing the app +/// target. +/// +/// The app target (today `TerminalController`, the interim composition owner; +/// later `TerminalControlComposition`) conforms by reading `AppDelegate` / +/// `Workspace` / `TabManager` state. Every method is `@MainActor` because its +/// conformer lives on the main actor and the coordinator runs there too, so +/// these are plain in-isolation calls — the per-read `v2MainSync` hops the +/// legacy command bodies used disappear once a domain moves onto the +/// coordinator. +/// +/// This protocol grows one domain at a time across stage 3c. It currently +/// covers the window domain; workspace/surface/pane/browser/sidebar reaches +/// land in later sub-stages. +@MainActor +public protocol ControlCommandContext: AnyObject { + /// Snapshots every main window for `window.list`, in window order. + func controlWindowSummaries() -> [ControlWindowSummary] + + /// Resolves the window targeted by the given routing selectors for + /// `window.current`, mirroring the legacy `v2ResolveTabManager` → + /// `windowId(for:)` precedence and its two distinct failures. + /// + /// - Parameter routing: The pre-resolved routing selectors. + /// - Returns: The resolution outcome. + func controlResolveCurrentWindow(routing: ControlRoutingSelectors) -> ControlCurrentWindowResolution + + /// Focuses the window with the given id for `window.focus`. + /// + /// - Parameter id: The window to focus. + /// - Returns: Whether a matching window was found and focused. + func controlFocusWindow(id: UUID) -> Bool + + /// Creates a new main window and makes it the active tab-manager target for + /// `window.create` (create + defensive activation, as the legacy body did). + /// + /// - Returns: The new window's id, or `nil` if creation failed. + func controlCreateWindowAndActivate() -> UUID? + + /// Closes the window with the given id for `window.close`. + /// + /// - Parameter id: The window to close. + /// - Returns: Whether a matching window was found and closed. + func controlCloseWindow(id: UUID) -> Bool + + /// Snapshots every connected display for `window.displays`, in screen order. + func controlAvailableDisplays() -> [ControlDisplayInfo] + + /// Whether a window with the given id currently exists (for the + /// `window.display` not-found disambiguation). + /// + /// - Parameter id: The window id to test. + /// - Returns: Whether the window exists. + func controlWindowExists(id: UUID) -> Bool + + /// Moves one window onto the display matched by `query` for + /// `window.display`, preserving size. + /// + /// - Parameters: + /// - id: The window to move. + /// - query: The display match query (name, substring, or index). + /// - Returns: The resolved display name, or `nil` when the window or display + /// can't be resolved. + func controlMoveWindow(id: UUID, toDisplayMatching query: String) -> String? + + /// Moves every main window onto the display matched by `query` for + /// `window.display`, preserving sizes. + /// + /// - Parameter query: The display match query. + /// - Returns: The resolved display name and moved window ids, or `nil` when + /// the display can't be resolved. + func controlMoveAllWindows(toDisplayMatching query: String) -> ControlMoveAllWindowsResult? +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift new file mode 100644 index 000000000000..1988b14df097 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift @@ -0,0 +1,154 @@ +internal import Foundation + +/// The window domain (`window.*`), lifted byte-faithfully from the former +/// `TerminalController.v2Window*` bodies. Each payload is built directly as a +/// ``JSONValue`` (the typed twin of the legacy `[String: Any]` dictionaries); +/// the resulting Foundation object is identical, so the encoded wire bytes +/// match. +extension ControlCommandCoordinator { + /// `window.list` — every main window, in order. + func windowList() -> ControlCallResult { + let windows = context?.controlWindowSummaries() ?? [] + let payload: [JSONValue] = windows.enumerated().map { index, item in + .object([ + "id": .string(item.windowID.uuidString), + "ref": ref(.window, item.windowID), + "index": .int(Int64(index)), + "key": .bool(item.isKeyWindow), + "visible": .bool(item.isVisible), + "workspace_count": .int(Int64(item.workspaceCount)), + "selected_workspace_id": orNull(item.selectedWorkspaceID?.uuidString), + "selected_workspace_ref": ref(.workspace, item.selectedWorkspaceID), + ]) + } + return .ok(.object(["windows": .array(payload)])) + } + + /// `window.current` — the window resolved from the routing selectors. + func windowCurrent(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = context?.controlResolveCurrentWindow(routing: routingSelectors(params)) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .windowNotFound: + return .err(code: "not_found", message: "Current window not found", data: nil) + case .resolved(let windowID): + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + /// `window.focus` — focus a window by id. + func windowFocus(_ params: [String: JSONValue]) -> ControlCallResult { + guard let windowID = uuid(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let ok = context?.controlFocusWindow(id: windowID) ?? false + let identity: JSONValue = .object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + ]) + return ok + ? .ok(identity) + : .err(code: "not_found", message: "Window not found", data: identity) + } + + /// `window.create` — create a window and make it active. + func windowCreate() -> ControlCallResult { + guard let windowID = context?.controlCreateWindowAndActivate() else { + return .err(code: "internal_error", message: "Failed to create window", data: nil) + } + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + + /// `window.close` — close a window by id. + func windowClose(_ params: [String: JSONValue]) -> ControlCallResult { + guard let windowID = uuid(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let ok = context?.controlCloseWindow(id: windowID) ?? false + let identity: JSONValue = .object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + ]) + return ok + ? .ok(identity) + : .err(code: "not_found", message: "Window not found", data: identity) + } + + /// `window.displays` — every connected display. + func windowDisplays() -> ControlCallResult { + let displays = context?.controlAvailableDisplays() ?? [] + let payload: [JSONValue] = displays.map { display in + .object([ + "name": .string(display.name), + "index": .int(Int64(display.index)), + "display_id": display.displayID.map { JSONValue.int(Int64(Int($0))) } ?? .null, + "main": .bool(display.isMain), + "frame": .object([ + "x": .int(Int64(Int(display.frameX))), + "y": .int(Int64(Int(display.frameY))), + "width": .int(Int64(Int(display.frameWidth))), + "height": .int(Int64(Int(display.frameHeight))), + ]), + ]) + } + return .ok(.object(["displays": .array(payload)])) + } + + /// `window.display` — move one window (or all windows) onto a display. + func windowDisplay(_ params: [String: JSONValue]) -> ControlCallResult { + guard let displayQuery = string(params, "display") else { + return .err(code: "invalid_params", message: "Missing or invalid display", data: nil) + } + + // Explicit window target moves just that window; otherwise move every + // main window of this instance (a dev build usually has one). + if let windowID = uuid(params, "window_id") { + if let display = context?.controlMoveWindow(id: windowID, toDisplayMatching: displayQuery) { + return .ok(.object([ + "display": .string(display), + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "moved": .array([.string(windowID.uuidString)]), + ])) + } + let windowExists = context?.controlWindowExists(id: windowID) ?? false + if !windowExists { + return .err(code: "not_found", message: "Window not found", data: .object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + return displayNotFound(displayQuery) + } + + guard let result = context?.controlMoveAllWindows(toDisplayMatching: displayQuery) else { + return displayNotFound(displayQuery) + } + return .ok(.object([ + "display": .string(result.display), + "moved": .array(result.windowIDs.map { .string($0.uuidString) }), + ])) + } + + /// The shared `display not found` error for `window.display`, carrying the + /// requested query and the available display names. + private func displayNotFound(_ requested: String) -> ControlCallResult { + let names = (context?.controlAvailableDisplays() ?? []).map(\.name) + return .err( + code: "not_found", + message: "Display not found: \(requested)", + data: .object([ + "requested": .string(requested), + "available": .array(names.map { .string($0) }), + ]) + ) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift new file mode 100644 index 000000000000..f34e0f1cbea1 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift @@ -0,0 +1,172 @@ +public import Foundation +internal import Observation + +/// The main-actor RPC dispatch half of the former `TerminalController`: it +/// receives decoded ``ControlRequest``s, runs the command logic against live +/// app state strictly through the read-only ``ControlCommandContext`` seam, and +/// returns a typed ``ControlCallResult``. It does no socket I/O and never +/// imports the app target. +/// +/// ## Isolation +/// +/// `@MainActor` because its sole collaborator (``ControlCommandContext``) lives +/// on the main actor and the legacy command bodies always executed on main +/// (the socket worker hopped once via `v2MainSync { processCommand }`). Running +/// the coordinator on main turns every former per-read `v2MainSync` hop into a +/// plain in-isolation call, so moved domains shed their hops outright. Worker- +/// lane methods (`vm.*`, `system.top`, …) that block or await are NOT handled +/// here; they stay on the app-side worker path. +/// +/// ## State ownership +/// +/// The coordinator owns the ``ControlHandleRegistry`` — the `kind:N` ref mint +/// that the RPC layer uses to hand opaque handles to callers. This is RPC +/// selection state, so it belongs here (the plan's state split). The interim +/// composition owner (`TerminalController`) routes its own `ensureRef` / +/// `resolveRef` / `removeRef` through the coordinator so refs stay consistent +/// across moved and not-yet-moved domains. +@MainActor +@Observable +public final class ControlCommandCoordinator { + /// The live app-state seam. Weak to avoid a retain cycle with the interim + /// composition owner, which owns the coordinator and sets this to `self` + /// during its own init. Not observation-tracked: it is wired once. + @ObservationIgnored + public weak var context: (any ControlCommandContext)? + + /// The shared `kind:N` handle registry. Single source of truth for ref + /// minting across the RPC layer. + public var handles: ControlHandleRegistry + + /// Creates a coordinator. + /// + /// - Parameters: + /// - context: The app-state seam. May be set after init (see ``context``). + /// - handles: The handle registry to adopt. Defaults to a fresh one. + public init( + context: (any ControlCommandContext)? = nil, + handles: ControlHandleRegistry = ControlHandleRegistry() + ) { + self.context = context + self.handles = handles + } + + // MARK: - Dispatch + + /// Runs one decoded request if it belongs to a domain this coordinator + /// owns, returning the typed result; returns `nil` for methods still served + /// by the legacy app-side dispatcher so the caller can fall through. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not owned here. + public func handle(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "window.list": + return windowList() + case "window.current": + return windowCurrent(request.params) + case "window.focus": + return windowFocus(request.params) + case "window.create": + return windowCreate() + case "window.close": + return windowClose(request.params) + case "window.displays": + return windowDisplays() + case "window.display": + return windowDisplay(request.params) + default: + return nil + } + } + + // MARK: - Handle registry (shared ref minting) + + /// Mints or returns the stable `kind:N` ref for an identifier. + /// + /// - Parameters: + /// - kind: The handle kind. + /// - uuid: The identifier to ref. + /// - Returns: The ref string. + @discardableResult + public func ensureRef(kind: ControlHandleKind, uuid: UUID) -> String { + handles.ensureRef(kind: kind, uuid: uuid) + } + + /// Resolves a previously-minted `kind:N` ref back to its identifier. + /// + /// - Parameter ref: The ref string. + /// - Returns: The identifier, or `nil` if unknown. + public func resolveRef(_ ref: String) -> UUID? { + handles.uuid(forRef: ref) + } + + /// Drops the ref for an identifier (without reusing its ordinal). + /// + /// - Parameters: + /// - kind: The handle kind. + /// - uuid: The identifier to forget. + public func removeRef(kind: ControlHandleKind, uuid: UUID) { + handles.removeRef(kind: kind, uuid: uuid) + } + + // MARK: - Wire helpers + + /// The `kind:N` ref for an optional id as a JSON value: the ref string, or + /// JSON `null` when the id is absent (the legacy `v2Ref` `NSNull` case). + func ref(_ kind: ControlHandleKind, _ uuid: UUID?) -> JSONValue { + guard let uuid else { return .null } + return .string(handles.ensureRef(kind: kind, uuid: uuid)) + } + + /// A string as a JSON value, or JSON `null` when absent (the legacy + /// `v2OrNull` `NSNull` case). + func orNull(_ value: String?) -> JSONValue { + guard let value else { return .null } + return .string(value) + } + + // MARK: - Param parsing (typed twin of v2String / v2UUID / v2HasNonNullParam) + + /// A trimmed, non-empty string param, or `nil` (matches legacy `v2String`: + /// only a JSON string counts; whitespace-only is treated as absent). + func string(_ params: [String: JSONValue], _ key: String) -> String? { + guard case .string(let raw)? = params[key] else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /// A UUID param, accepting either a UUID string or a `kind:N` ref resolved + /// through the handle registry (matches legacy `v2UUID`). + func uuid(_ params: [String: JSONValue], _ key: String) -> UUID? { + guard let raw = string(params, key) else { return nil } + if let parsed = UUID(uuidString: raw) { + return parsed + } + return handles.uuid(forRef: raw) + } + + /// Whether a param is present and not JSON `null` (matches legacy + /// `v2HasNonNullParam`). + func hasNonNull(_ params: [String: JSONValue], _ key: String) -> Bool { + guard let value = params[key] else { return false } + if case .null = value { return false } + return true + } + + /// Builds the routing selectors for `window.current`, resolving each + /// selector through the handle registry exactly as the legacy + /// `v2ResolveTabManager` did before walking its precedence. + func routingSelectors(_ params: [String: JSONValue]) -> ControlRoutingSelectors { + ControlRoutingSelectors( + hasWindowIDParam: hasNonNull(params, "window_id"), + windowID: uuid(params, "window_id"), + groupID: uuid(params, "group_id"), + workspaceID: uuid(params, "workspace_id"), + surfaceID: uuid(params, "surface_id") + ?? uuid(params, "terminal_id") + ?? uuid(params, "tab_id"), + paneID: uuid(params, "pane_id") + ) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCurrentWindowResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCurrentWindowResolution.swift new file mode 100644 index 000000000000..a1c7ffd551fe --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCurrentWindowResolution.swift @@ -0,0 +1,16 @@ +public import Foundation + +/// The outcome of resolving the `window.current` target, preserving the two +/// distinct legacy failures: the routing selectors resolved no TabManager +/// (`unavailable`) versus a TabManager resolved but had no window id +/// (`not_found`). +public enum ControlCurrentWindowResolution: Sendable, Equatable { + /// A window id resolved. + case resolved(UUID) + /// No TabManager resolved from the routing selectors (legacy + /// `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// A TabManager resolved but its window id could not be found (legacy + /// `not_found` / "Current window not found"). + case windowNotFound +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlDisplayInfo.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlDisplayInfo.swift new file mode 100644 index 000000000000..11788daf06ef --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlDisplayInfo.swift @@ -0,0 +1,56 @@ +/// A read-only snapshot of one connected display, as the app target exposes it +/// to ``ControlCommandCoordinator`` through ``ControlCommandContext``. +/// +/// Mirrors the app target's `AppDelegate.DisplayInfo` without the package +/// importing AppKit: the frame is carried as four scalars (the coordinator +/// truncates them to `Int` for the `window.displays` payload, matching the +/// legacy `Int(frame.origin.x)` conversion). +public struct ControlDisplayInfo: Sendable, Equatable { + /// The display's localized name. + public let name: String + /// The display's zero-based index in screen order. + public let index: Int + /// The Core Graphics display id, if available. + public let displayID: UInt32? + /// Whether this is the main display. + public let isMain: Bool + /// The display frame's minimum-x origin, in points. + public let frameX: Double + /// The display frame's minimum-y origin, in points. + public let frameY: Double + /// The display frame width, in points. + public let frameWidth: Double + /// The display frame height, in points. + public let frameHeight: Double + + /// Creates a display snapshot. + /// + /// - Parameters: + /// - name: The display's localized name. + /// - index: The zero-based screen-order index. + /// - displayID: The Core Graphics display id, if available. + /// - isMain: Whether this is the main display. + /// - frameX: The frame origin x, in points. + /// - frameY: The frame origin y, in points. + /// - frameWidth: The frame width, in points. + /// - frameHeight: The frame height, in points. + public init( + name: String, + index: Int, + displayID: UInt32?, + isMain: Bool, + frameX: Double, + frameY: Double, + frameWidth: Double, + frameHeight: Double + ) { + self.name = name + self.index = index + self.displayID = displayID + self.isMain = isMain + self.frameX = frameX + self.frameY = frameY + self.frameWidth = frameWidth + self.frameHeight = frameHeight + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMoveAllWindowsResult.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMoveAllWindowsResult.swift new file mode 100644 index 000000000000..7b5b208286d2 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMoveAllWindowsResult.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// The outcome of moving every main window onto one display, returned by +/// ``ControlCommandContext/controlMoveAllWindows(toDisplayMatching:)``. +public struct ControlMoveAllWindowsResult: Sendable, Equatable { + /// The resolved display's localized name. + public let display: String + /// The identifiers of the windows that were moved. + public let windowIDs: [UUID] + + /// Creates a move-all result. + /// + /// - Parameters: + /// - display: The resolved display name. + /// - windowIDs: The moved window identifiers. + public init(display: String, windowIDs: [UUID]) { + self.display = display + self.windowIDs = windowIDs + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlRoutingSelectors.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlRoutingSelectors.swift new file mode 100644 index 000000000000..98a6df64e292 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlRoutingSelectors.swift @@ -0,0 +1,57 @@ +public import Foundation + +/// The pre-resolved routing selectors a control command carries to pick the +/// window/workspace it targets. +/// +/// ``ControlCommandCoordinator`` parses these from the request params (resolving +/// `kind:N` refs through its handle registry, exactly as the legacy `v2UUID` +/// did) and hands them to ``ControlCommandContext`` so the app target can run +/// the same precedence walk the former `v2ResolveTabManager` used, without the +/// package importing `TabManager`. +/// +/// Precedence (highest first), preserved from the legacy resolver: an explicit +/// `window_id` param wins outright (and a present-but-unresolvable `window_id` +/// resolves to no target); then group, then workspace, then surface, then pane; +/// finally the caller's own window, then the active scriptable window. +public struct ControlRoutingSelectors: Sendable, Equatable { + /// Whether the request carried a non-null `window_id` param at all. A + /// present-but-unresolvable `window_id` must resolve to no target rather + /// than falling through to the other selectors (legacy behavior). + public let hasWindowIDParam: Bool + /// The resolved `window_id` target, if the param parsed to a known window. + public let windowID: UUID? + /// The resolved `group_id` target, if any. + public let groupID: UUID? + /// The resolved `workspace_id` target, if any. + public let workspaceID: UUID? + /// The resolved surface target (`surface_id`, then `terminal_id`, then + /// `tab_id`), if any. + public let surfaceID: UUID? + /// The resolved `pane_id` target, if any. + public let paneID: UUID? + + /// Creates a routing-selectors value. + /// + /// - Parameters: + /// - hasWindowIDParam: Whether a non-null `window_id` param was present. + /// - windowID: The resolved `window_id` target. + /// - groupID: The resolved `group_id` target. + /// - workspaceID: The resolved `workspace_id` target. + /// - surfaceID: The resolved surface target. + /// - paneID: The resolved `pane_id` target. + public init( + hasWindowIDParam: Bool, + windowID: UUID?, + groupID: UUID?, + workspaceID: UUID?, + surfaceID: UUID?, + paneID: UUID? + ) { + self.hasWindowIDParam = hasWindowIDParam + self.windowID = windowID + self.groupID = groupID + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.paneID = paneID + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowSummary.swift new file mode 100644 index 000000000000..f0972d29a292 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowSummary.swift @@ -0,0 +1,42 @@ +public import Foundation + +/// A read-only snapshot of one main window, as the app target exposes it to +/// ``ControlCommandCoordinator`` through ``ControlCommandContext``. +/// +/// Mirrors the app target's `AppDelegate.MainWindowSummary` without the package +/// importing the app target. The coordinator turns each summary into the +/// `window.list` payload row. +public struct ControlWindowSummary: Sendable, Equatable { + /// The window's stable identifier. + public let windowID: UUID + /// Whether this is the key (frontmost-active) window. + public let isKeyWindow: Bool + /// Whether the window is currently on screen. + public let isVisible: Bool + /// How many workspaces the window currently holds. + public let workspaceCount: Int + /// The currently-selected workspace in this window, if any. + public let selectedWorkspaceID: UUID? + + /// Creates a window summary. + /// + /// - Parameters: + /// - windowID: The window's stable identifier. + /// - isKeyWindow: Whether this is the key window. + /// - isVisible: Whether the window is on screen. + /// - workspaceCount: How many workspaces the window holds. + /// - selectedWorkspaceID: The selected workspace, if any. + public init( + windowID: UUID, + isKeyWindow: Bool, + isVisible: Bool, + workspaceCount: Int, + selectedWorkspaceID: UUID? + ) { + self.windowID = windowID + self.isKeyWindow = isKeyWindow + self.isVisible = isVisible + self.workspaceCount = workspaceCount + self.selectedWorkspaceID = selectedWorkspaceID + } +} diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift new file mode 100644 index 000000000000..f75a16aab3b7 --- /dev/null +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift @@ -0,0 +1,340 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +/// A scriptable ``ControlCommandContext`` for driving the window coordinator +/// without the app target. +@MainActor +private final class FakeControlCommandContext: ControlCommandContext { + var windowSummaries: [ControlWindowSummary] = [] + var currentWindowResolution: ControlCurrentWindowResolution = .tabManagerUnavailable + var lastRouting: ControlRoutingSelectors? + var focusResult = false + var focusedID: UUID? + var createResult: UUID? + var closeResult = false + var closedID: UUID? + var displays: [ControlDisplayInfo] = [] + var existingWindowIDs: Set = [] + var moveWindowResult: String? + var movedWindow: (id: UUID, query: String)? + var moveAllResult: ControlMoveAllWindowsResult? + + func controlWindowSummaries() -> [ControlWindowSummary] { windowSummaries } + + func controlResolveCurrentWindow(routing: ControlRoutingSelectors) -> ControlCurrentWindowResolution { + lastRouting = routing + return currentWindowResolution + } + + func controlFocusWindow(id: UUID) -> Bool { + focusedID = id + return focusResult + } + + func controlCreateWindowAndActivate() -> UUID? { createResult } + + func controlCloseWindow(id: UUID) -> Bool { + closedID = id + return closeResult + } + + func controlAvailableDisplays() -> [ControlDisplayInfo] { displays } + + func controlWindowExists(id: UUID) -> Bool { existingWindowIDs.contains(id) } + + func controlMoveWindow(id: UUID, toDisplayMatching query: String) -> String? { + movedWindow = (id, query) + return moveWindowResult + } + + func controlMoveAllWindows(toDisplayMatching query: String) -> ControlMoveAllWindowsResult? { + moveAllResult + } +} + +@MainActor +@Suite("ControlCommandCoordinator window domain") +struct ControlCommandCoordinatorWindowTests { + private func makeCoordinator() -> (ControlCommandCoordinator, FakeControlCommandContext) { + let context = FakeControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + return (coordinator, context) + } + + private func request(_ method: String, _ params: [String: JSONValue] = [:]) -> ControlRequest { + ControlRequest(id: .int(1), method: method, params: params) + } + + @Test func unownedMethodFallsThrough() { + let (coordinator, _) = makeCoordinator() + #expect(coordinator.handle(request("workspace.list")) == nil) + } + + @Test func windowListBuildsRowsWithMintedRefs() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + let workspaceID = UUID() + context.windowSummaries = [ + ControlWindowSummary( + windowID: windowID, + isKeyWindow: true, + isVisible: true, + workspaceCount: 3, + selectedWorkspaceID: workspaceID + ), + ] + let result = coordinator.handle(request("window.list")) + // First mint of each kind is ordinal 1. + #expect(result == .ok(.object([ + "windows": .array([ + .object([ + "id": .string(windowID.uuidString), + "ref": .string("window:1"), + "index": .int(0), + "key": .bool(true), + "visible": .bool(true), + "workspace_count": .int(3), + "selected_workspace_id": .string(workspaceID.uuidString), + "selected_workspace_ref": .string("workspace:1"), + ]), + ]), + ]))) + } + + @Test func windowListNilSelectedWorkspaceIsNull() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.windowSummaries = [ + ControlWindowSummary( + windowID: windowID, + isKeyWindow: false, + isVisible: false, + workspaceCount: 0, + selectedWorkspaceID: nil + ), + ] + guard case .ok(.object(let payload)) = coordinator.handle(request("window.list")), + case .array(let rows) = payload["windows"], + case .object(let row) = rows.first else { + Issue.record("unexpected shape") + return + } + #expect(row["selected_workspace_id"] == .null) + #expect(row["selected_workspace_ref"] == .null) + } + + @Test func windowCurrentResolvesAndMintsRef() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.currentWindowResolution = .resolved(windowID) + let result = coordinator.handle(request("window.current")) + #expect(result == .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": .string("window:1"), + ]))) + } + + @Test func windowCurrentTabManagerUnavailable() { + let (coordinator, context) = makeCoordinator() + context.currentWindowResolution = .tabManagerUnavailable + #expect(coordinator.handle(request("window.current")) + == .err(code: "unavailable", message: "TabManager not available", data: nil)) + } + + @Test func windowCurrentWindowNotFound() { + let (coordinator, context) = makeCoordinator() + context.currentWindowResolution = .windowNotFound + #expect(coordinator.handle(request("window.current")) + == .err(code: "not_found", message: "Current window not found", data: nil)) + } + + @Test func windowCurrentParsesRoutingSelectors() { + let (coordinator, context) = makeCoordinator() + let workspaceID = UUID() + _ = coordinator.handle(request("window.current", [ + "workspace_id": .string(workspaceID.uuidString), + "window_id": .null, + ])) + #expect(context.lastRouting?.hasWindowIDParam == false) + #expect(context.lastRouting?.workspaceID == workspaceID) + } + + @Test func windowFocusOkAndNotFound() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.focusResult = true + let okResult = coordinator.handle(request("window.focus", ["window_id": .string(windowID.uuidString)])) + #expect(context.focusedID == windowID) + #expect(okResult == .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": .string("window:1"), + ]))) + + context.focusResult = false + let notFound = coordinator.handle(request("window.focus", ["window_id": .string(windowID.uuidString)])) + #expect(notFound == .err(code: "not_found", message: "Window not found", data: .object([ + "window_id": .string(windowID.uuidString), + "window_ref": .string("window:1"), + ]))) + } + + @Test func windowFocusInvalidParams() { + let (coordinator, _) = makeCoordinator() + #expect(coordinator.handle(request("window.focus")) + == .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil)) + } + + @Test func windowCreateOkAndFailure() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.createResult = windowID + #expect(coordinator.handle(request("window.create")) == .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": .string("window:1"), + ]))) + + context.createResult = nil + #expect(coordinator.handle(request("window.create")) + == .err(code: "internal_error", message: "Failed to create window", data: nil)) + } + + @Test func windowCloseOkAndNotFound() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.closeResult = true + #expect(coordinator.handle(request("window.close", ["window_id": .string(windowID.uuidString)])) + == .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": .string("window:1"), + ]))) + #expect(context.closedID == windowID) + } + + @Test func windowDisplaysBuildsPayload() { + let (coordinator, context) = makeCoordinator() + context.displays = [ + ControlDisplayInfo( + name: "LG HDR 4K", + index: 0, + displayID: 42, + isMain: true, + frameX: 0, + frameY: 0, + frameWidth: 3840.7, + frameHeight: 2160.9 + ), + ControlDisplayInfo( + name: "Sidecar", + index: 1, + displayID: nil, + isMain: false, + frameX: -100.4, + frameY: 12.6, + frameWidth: 1280, + frameHeight: 800 + ), + ] + #expect(coordinator.handle(request("window.displays")) == .ok(.object([ + "displays": .array([ + .object([ + "name": .string("LG HDR 4K"), + "index": .int(0), + "display_id": .int(42), + "main": .bool(true), + "frame": .object([ + "x": .int(0), "y": .int(0), "width": .int(3840), "height": .int(2160), + ]), + ]), + .object([ + "name": .string("Sidecar"), + "index": .int(1), + "display_id": .null, + "main": .bool(false), + "frame": .object([ + // Int() truncates toward zero, matching Int(frame.origin.x). + "x": .int(-100), "y": .int(12), "width": .int(1280), "height": .int(800), + ]), + ]), + ]), + ]))) + } + + @Test func windowDisplayMovesSingleWindow() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.moveWindowResult = "LG HDR 4K" + #expect(coordinator.handle(request("window.display", [ + "display": .string("LG"), + "window_id": .string(windowID.uuidString), + ])) == .ok(.object([ + "display": .string("LG HDR 4K"), + "window_id": .string(windowID.uuidString), + "window_ref": .string("window:1"), + "moved": .array([.string(windowID.uuidString)]), + ]))) + #expect(context.movedWindow?.query == "LG") + } + + @Test func windowDisplayWindowNotFound() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.moveWindowResult = nil + context.existingWindowIDs = [] + #expect(coordinator.handle(request("window.display", [ + "display": .string("LG"), + "window_id": .string(windowID.uuidString), + ])) == .err(code: "not_found", message: "Window not found", data: .object([ + "window_id": .string(windowID.uuidString), + "window_ref": .string("window:1"), + ]))) + } + + @Test func windowDisplayDisplayNotFoundListsAvailable() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + context.moveWindowResult = nil + context.existingWindowIDs = [windowID] + context.displays = [ + ControlDisplayInfo( + name: "Built-in", index: 0, displayID: 1, isMain: true, + frameX: 0, frameY: 0, frameWidth: 1, frameHeight: 1 + ), + ] + #expect(coordinator.handle(request("window.display", [ + "display": .string("Nope"), + "window_id": .string(windowID.uuidString), + ])) == .err(code: "not_found", message: "Display not found: Nope", data: .object([ + "requested": .string("Nope"), + "available": .array([.string("Built-in")]), + ]))) + } + + @Test func windowDisplayMovesAllWindows() { + let (coordinator, context) = makeCoordinator() + let a = UUID() + let b = UUID() + context.moveAllResult = ControlMoveAllWindowsResult(display: "LG HDR 4K", windowIDs: [a, b]) + #expect(coordinator.handle(request("window.display", ["display": .string("LG")])) + == .ok(.object([ + "display": .string("LG HDR 4K"), + "moved": .array([.string(a.uuidString), .string(b.uuidString)]), + ]))) + } + + @Test func windowDisplayInvalidParams() { + let (coordinator, _) = makeCoordinator() + #expect(coordinator.handle(request("window.display")) + == .err(code: "invalid_params", message: "Missing or invalid display", data: nil)) + } + + @Test func uuidParamResolvesMintedRef() { + let (coordinator, context) = makeCoordinator() + let windowID = UUID() + let ref = coordinator.ensureRef(kind: .window, uuid: windowID) + context.focusResult = true + // Passing the ref string instead of the UUID resolves to the same window. + _ = coordinator.handle(request("window.focus", ["window_id": .string(ref)])) + #expect(context.focusedID == windowID) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 10b55af8698f..8da239ac122a 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -202,10 +202,12 @@ class TerminalController { "feed.jump" ] - /// Mints/resolves the stable `kind:N` handle refs handed to v2 callers - /// (`ControlHandleKind` + `ControlHandleRegistry` live in - /// CmuxControlSocket; main-actor isolation is provided here). - private var v2Handles = ControlHandleRegistry() + /// The main-actor RPC dispatch coordinator (CmuxControlSocket). Owns the + /// `kind:N` handle registry and the moved command domains (window so far, + /// growing per stage-3c sub-stage); this controller is its interim + /// composition owner and ``ControlCommandContext`` conformer. Constructed in + /// `init`; its `context` is wired to `self` once `self` is available. + let controlCommandCoordinator = ControlCommandCoordinator() private struct V2BrowserElementRefEntry { let surfaceId: UUID @@ -247,7 +249,7 @@ class TerminalController { v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId) v2BrowserElementRefs = v2BrowserElementRefs.filter { $0.value.surfaceId != surfaceId } - v2Handles.removeRef(kind: .surface, uuid: surfaceId) + controlCommandCoordinator.removeRef(kind: .surface, uuid: surfaceId) } } @@ -286,6 +288,7 @@ class TerminalController { } } serverEventTarget.controller = self + controlCommandCoordinator.context = self browserDownloadObserver = NotificationCenter.default.addObserver( forName: .browserDownloadEventDidArrive, object: nil, @@ -1886,6 +1889,15 @@ class TerminalController { v2MainSync { self.v2RefreshKnownRefs() } + // Domains migrated into CmuxControlSocket's ControlCommandCoordinator + // (window so far) answer here, on the main actor, and encode through + // the same encoder/id; everything else falls through to the legacy + // switch below. processV2Command already runs on main, so the + // coordinator's bodies need no per-read v2MainSync hop. + if let coordinatorResult = controlCommandCoordinator.handle(request) { + return Self.v2Encoder.response(id: request.id, coordinatorResult) + } + switch method { case "system.ping": return v2Ok(id: id, result: ["pong": true]) @@ -1929,21 +1941,7 @@ class TerminalController { ] ) - // 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)) - case "window.displays": - return v2Result(id: id, self.v2WindowDisplays(params: params)) - case "window.display": - return v2Result(id: id, self.v2WindowDisplay(params: params)) + // Windows (`window.*`) are handled above by ControlCommandCoordinator. // Workspaces case "workspace.list": @@ -3832,11 +3830,11 @@ class TerminalController { } private func v2EnsureHandleRef(kind: ControlHandleKind, uuid: UUID) -> String { - v2Handles.ensureRef(kind: kind, uuid: uuid) + controlCommandCoordinator.ensureRef(kind: kind, uuid: uuid) } func v2ResolveHandleRef(_ handle: String) -> UUID? { - v2Handles.uuid(forRef: handle) + controlCommandCoordinator.resolveRef(handle) } func v2Ref(kind: ControlHandleKind, uuid: UUID?) -> Any { @@ -3991,163 +3989,44 @@ class TerminalController { return nil } - func v2ResolveWindowId(tabManager: TabManager?) -> UUID? { - guard let tabManager else { return nil } - return v2MainSync { AppDelegate.shared?.windowId(for: tabManager) } - } - - private func v2ResolveWorkspaceOwner(_ workspaceId: UUID) -> TabManager? { - v2MainSync { AppDelegate.shared?.tabManagerFor(tabId: workspaceId) } - } - - // 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) - ] + /// Mirrors the former `v2ResolveTabManager` precedence for the + /// ``ControlCommandContext`` window resolution, operating on selectors the + /// coordinator already resolved through the shared handle registry: explicit + /// `window_id` wins (a present-but-unresolvable one yields no target), then + /// group, workspace, surface, pane, then the caller's window, then the + /// active scriptable window. Lives here so it can read the controller's + /// `private` `tabManager` / `v2LocateTabManager`. + func resolveTabManager(routing: ControlRoutingSelectors) -> TabManager? { + if routing.hasWindowIDParam { + guard let windowId = routing.windowID else { return nil } + return AppDelegate.shared?.tabManagerFor(windowId: windowId) } - return .ok(["windows": payload]) - } - - private func v2WindowCurrent(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + if let groupId = routing.groupID, + let tm = v2LocateTabManager(forGroupId: groupId) { + return tm } - guard let windowId = v2ResolveWindowId(tabManager: tabManager) else { - return .err(code: "not_found", message: "Current window not found", data: nil) + if let workspaceId = routing.workspaceID, + let tm = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) { + return tm } - 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) + if let surfaceId = routing.surfaceID, + let tm = AppDelegate.shared?.locateSurface(surfaceId: surfaceId)?.tabManager { + return tm } - // The new window should become key, but setActiveTabManager defensively. - if let tm = v2MainSync({ AppDelegate.shared?.tabManagerFor(windowId: windowId) }) { - setActiveTabManager(tm) + if let paneId = routing.paneID, + let tm = v2LocatePane(paneId)?.tabManager { + return tm } - return .ok([ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId) - ]) + return tabManager ?? AppDelegate.shared?.currentScriptableMainWindow()?.tabManager } - 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) - ]) - } - - private func v2WindowDisplays(params _: [String: Any]) -> V2CallResult { - let displays = v2MainSync { AppDelegate.shared?.availableDisplays() } ?? [] - let payload: [[String: Any]] = displays.map { display in - [ - "name": display.name, - "index": display.index, - "display_id": v2OrNull(display.displayID.map { Int($0) }), - "main": display.isMain, - "frame": [ - "x": Int(display.frame.origin.x), - "y": Int(display.frame.origin.y), - "width": Int(display.frame.width), - "height": Int(display.frame.height) - ] - ] - } - return .ok(["displays": payload]) - } - - private func v2WindowDisplay(params: [String: Any]) -> V2CallResult { - guard let displayQuery = (params["display"] as? String)? - .trimmingCharacters(in: .whitespacesAndNewlines), - !displayQuery.isEmpty else { - return .err(code: "invalid_params", message: "Missing or invalid display", data: nil) - } - - // Explicit window target moves just that window; otherwise move every main - // window of this instance (a dev build usually has one). - if let windowId = v2UUID(params, "window_id") { - let resolved = v2MainSync { - AppDelegate.shared?.moveMainWindow(windowId: windowId, toDisplayMatching: displayQuery) - } - if let display = resolved { - return .ok([ - "display": display, - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId), - "moved": [windowId.uuidString] - ]) - } - let windowExists = v2MainSync { - AppDelegate.shared?.windowForMainWindowId(windowId) != nil - } - if !windowExists { - return .err(code: "not_found", message: "Window not found", data: [ - "window_id": windowId.uuidString, - "window_ref": v2Ref(kind: .window, uuid: windowId) - ]) - } - return v2DisplayNotFound(displayQuery) - } - - guard let result = v2MainSync({ - AppDelegate.shared?.moveAllMainWindows(toDisplayMatching: displayQuery) - }) else { - return v2DisplayNotFound(displayQuery) - } - return .ok([ - "display": result.display, - "moved": result.windowIds.map { $0.uuidString } - ]) + func v2ResolveWindowId(tabManager: TabManager?) -> UUID? { + guard let tabManager else { return nil } + return v2MainSync { AppDelegate.shared?.windowId(for: tabManager) } } - private func v2DisplayNotFound(_ requested: String) -> V2CallResult { - let names = v2MainSync { AppDelegate.shared?.availableDisplays().map(\.name) } ?? [] - return .err( - code: "not_found", - message: "Display not found: \(requested)", - data: ["requested": requested, "available": names] - ) + private func v2ResolveWorkspaceOwner(_ workspaceId: UUID) -> TabManager? { + v2MainSync { AppDelegate.shared?.tabManagerFor(tabId: workspaceId) } } // MARK: - V2 Workspace Methods diff --git a/Sources/TerminalControllerControlCommandContext.swift b/Sources/TerminalControllerControlCommandContext.swift new file mode 100644 index 000000000000..e212acaa22d6 --- /dev/null +++ b/Sources/TerminalControllerControlCommandContext.swift @@ -0,0 +1,86 @@ +import CmuxControlSocket +import Foundation + +/// `TerminalController` conforms to ``ControlCommandContext`` as the interim +/// composition owner for the stage-3c ``ControlCommandCoordinator``: it reads +/// live `AppDelegate` / `TabManager` state on the main actor so the coordinator +/// (which runs on main, inside the active `withSocketCommandPolicy` stack) can +/// execute moved command domains without the package importing the app target. +/// +/// These methods are the byte-faithful bodies of the former `v2Window*` +/// dispatchers, minus the per-read `v2MainSync` hop: the coordinator already +/// runs on the main actor inside the socket-command policy scope, so each hop +/// would re-apply the identical thread-local focus-allowance stack — a no-op. +extension TerminalController: ControlCommandContext { + func controlWindowSummaries() -> [ControlWindowSummary] { + (AppDelegate.shared?.listMainWindowSummaries() ?? []).map { summary in + ControlWindowSummary( + windowID: summary.windowId, + isKeyWindow: summary.isKeyWindow, + isVisible: summary.isVisible, + workspaceCount: summary.workspaceCount, + selectedWorkspaceID: summary.selectedWorkspaceId + ) + } + } + + func controlResolveCurrentWindow( + routing: ControlRoutingSelectors + ) -> ControlCurrentWindowResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let windowId = AppDelegate.shared?.windowId(for: tabManager) else { + return .windowNotFound + } + return .resolved(windowId) + } + + func controlFocusWindow(id: UUID) -> Bool { + AppDelegate.shared?.focusMainWindow(windowId: id) ?? false + } + + func controlCreateWindowAndActivate() -> UUID? { + guard let windowId = AppDelegate.shared?.createMainWindow() else { return nil } + // The new window should become key, but setActiveTabManager defensively + // (preserves the legacy v2WindowCreate side effect and ordering). + if let tabManager = AppDelegate.shared?.tabManagerFor(windowId: windowId) { + setActiveTabManager(tabManager) + } + return windowId + } + + func controlCloseWindow(id: UUID) -> Bool { + AppDelegate.shared?.closeMainWindow(windowId: id) ?? false + } + + func controlAvailableDisplays() -> [ControlDisplayInfo] { + (AppDelegate.shared?.availableDisplays() ?? []).map { display in + ControlDisplayInfo( + name: display.name, + index: display.index, + displayID: display.displayID, + isMain: display.isMain, + frameX: display.frame.origin.x, + frameY: display.frame.origin.y, + frameWidth: display.frame.width, + frameHeight: display.frame.height + ) + } + } + + func controlWindowExists(id: UUID) -> Bool { + AppDelegate.shared?.windowForMainWindowId(id) != nil + } + + func controlMoveWindow(id: UUID, toDisplayMatching query: String) -> String? { + AppDelegate.shared?.moveMainWindow(windowId: id, toDisplayMatching: query) + } + + func controlMoveAllWindows(toDisplayMatching query: String) -> ControlMoveAllWindowsResult? { + guard let result = AppDelegate.shared?.moveAllMainWindows(toDisplayMatching: query) else { + return nil + } + return ControlMoveAllWindowsResult(display: result.display, windowIDs: result.windowIds) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bcaca40b6d76..d54214ad31e4 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -609,6 +609,7 @@ C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; + C0DE00000000000000000C32 /* TerminalControllerControlCommandContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */; }; C7A50A000000000000000002 /* TerminalControllerPaneResizeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A50A000000000000000001 /* TerminalControllerPaneResizeSupport.swift */; }; 8C4BBF2DEF6DF93F395A9EE7 /* TerminalControllerSocketSecurityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */; }; 9C1BEA3D2E6F49709A71C020 /* TerminalControllerTerminalTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C1BEA3D2E6F49709A71C021 /* TerminalControllerTerminalTextTests.swift */; }; @@ -1344,6 +1345,7 @@ C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; + C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerControlCommandContext.swift; sourceTree = ""; }; C7A50A000000000000000001 /* TerminalControllerPaneResizeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerPaneResizeSupport.swift; sourceTree = ""; }; 491751CE2321474474F27DCF /* TerminalControllerSocketSecurityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerSocketSecurityTests.swift; sourceTree = ""; }; 9C1BEA3D2E6F49709A71C021 /* TerminalControllerTerminalTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerTerminalTextTests.swift; sourceTree = ""; }; @@ -1847,6 +1849,7 @@ A5001440A5001440A5001440 /* FileOpenSocketSupport.swift */, D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */, C7A50A000000000000000001 /* TerminalControllerPaneResizeSupport.swift */, + C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */, C7A50B000000000000000001 /* TerminalControllerV2ParamParsingSupport.swift */, C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, @@ -3016,6 +3019,7 @@ C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, + C0DE00000000000000000C32 /* TerminalControllerControlCommandContext.swift in Sources */, C7A50A000000000000000002 /* TerminalControllerPaneResizeSupport.swift in Sources */, C7A505000000000000000002 /* TerminalControllerTopSupport.swift in Sources */, C7A50B000000000000000002 /* TerminalControllerV2ParamParsingSupport.swift in Sources */, From 2383068f3d063f99b125ce31736e80365cc24f5c Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 13:40:42 -0700 Subject: [PATCH 02/21] stage 3c: multi-protocol seam umbrella + shared param/ref helper port Restructure the seam into a per-domain protocol umbrella (ControlCommandContext: ControlWindowContext, ...) so each domain can be built in its own files, and port the shared TerminalControllerV2ParamParsingSupport pure helpers + ref minting (workspaceRefs/tabRef/workspacePaneAndSurfaceRefs) into the coordinator as JSONValue twins. Foundation for moving the remaining RPC domains. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Coordinator/ControlCommandContext.swift | 79 +------ .../ControlCommandCoordinator+Params.swift | 213 ++++++++++++++++++ .../Coordinator/ControlWindowContext.swift | 71 ++++++ ...minalControllerControlCommandContext.swift | 15 +- 4 files changed, 303 insertions(+), 75 deletions(-) create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift index 20fd89d250d1..b0dd5cb5054c 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift @@ -1,76 +1,15 @@ -public import Foundation - /// The read-only seam through which ``ControlCommandCoordinator`` reaches live /// app state to run control commands, without the package importing the app /// target. /// -/// The app target (today `TerminalController`, the interim composition owner; -/// later `TerminalControlComposition`) conforms by reading `AppDelegate` / -/// `Workspace` / `TabManager` state. Every method is `@MainActor` because its -/// conformer lives on the main actor and the coordinator runs there too, so -/// these are plain in-isolation calls — the per-read `v2MainSync` hops the -/// legacy command bodies used disappear once a domain moves onto the -/// coordinator. +/// It is an umbrella of one protocol per command domain so the domains can be +/// built independently (each domain owns its own seam-protocol file and its own +/// app-conformance file). The coordinator stores `any ControlCommandContext` +/// and reaches every member through this inheritance. The app target (today +/// `TerminalController`, the interim composition owner; later +/// `TerminalControlComposition`) conforms by conforming to each constituent. /// -/// This protocol grows one domain at a time across stage 3c. It currently -/// covers the window domain; workspace/surface/pane/browser/sidebar reaches -/// land in later sub-stages. +/// `AnyObject` so the coordinator can hold the conformer `weak` and avoid a +/// retain cycle with its composition owner. @MainActor -public protocol ControlCommandContext: AnyObject { - /// Snapshots every main window for `window.list`, in window order. - func controlWindowSummaries() -> [ControlWindowSummary] - - /// Resolves the window targeted by the given routing selectors for - /// `window.current`, mirroring the legacy `v2ResolveTabManager` → - /// `windowId(for:)` precedence and its two distinct failures. - /// - /// - Parameter routing: The pre-resolved routing selectors. - /// - Returns: The resolution outcome. - func controlResolveCurrentWindow(routing: ControlRoutingSelectors) -> ControlCurrentWindowResolution - - /// Focuses the window with the given id for `window.focus`. - /// - /// - Parameter id: The window to focus. - /// - Returns: Whether a matching window was found and focused. - func controlFocusWindow(id: UUID) -> Bool - - /// Creates a new main window and makes it the active tab-manager target for - /// `window.create` (create + defensive activation, as the legacy body did). - /// - /// - Returns: The new window's id, or `nil` if creation failed. - func controlCreateWindowAndActivate() -> UUID? - - /// Closes the window with the given id for `window.close`. - /// - /// - Parameter id: The window to close. - /// - Returns: Whether a matching window was found and closed. - func controlCloseWindow(id: UUID) -> Bool - - /// Snapshots every connected display for `window.displays`, in screen order. - func controlAvailableDisplays() -> [ControlDisplayInfo] - - /// Whether a window with the given id currently exists (for the - /// `window.display` not-found disambiguation). - /// - /// - Parameter id: The window id to test. - /// - Returns: Whether the window exists. - func controlWindowExists(id: UUID) -> Bool - - /// Moves one window onto the display matched by `query` for - /// `window.display`, preserving size. - /// - /// - Parameters: - /// - id: The window to move. - /// - query: The display match query (name, substring, or index). - /// - Returns: The resolved display name, or `nil` when the window or display - /// can't be resolved. - func controlMoveWindow(id: UUID, toDisplayMatching query: String) -> String? - - /// Moves every main window onto the display matched by `query` for - /// `window.display`, preserving sizes. - /// - /// - Parameter query: The display match query. - /// - Returns: The resolved display name and moved window ids, or `nil` when - /// the display can't be resolved. - func controlMoveAllWindows(toDisplayMatching query: String) -> ControlMoveAllWindowsResult? -} +public protocol ControlCommandContext: AnyObject, ControlWindowContext {} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift new file mode 100644 index 000000000000..11912ec5d382 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift @@ -0,0 +1,213 @@ +internal import Foundation + +/// The typed twins of the former `TerminalControllerV2ParamParsingSupport` +/// helpers, operating on `[String: JSONValue]` (the coordinator receives typed +/// params) instead of `[String: Any]`. Each mirrors its legacy counterpart's +/// acceptance rules so moved command bodies parse identically. +/// +/// App-coupled legacy helpers (`v2LocatePane` → app types, `v2PanelType` → +/// Bonsplit) are NOT here: those resolve through the domain seams or a +/// Sendable enum the app maps. +extension ControlCommandCoordinator { + /// `v2RawString`: the raw string value, untrimmed, or `nil`. + func rawString(_ params: [String: JSONValue], _ key: String) -> String? { + guard case .string(let value)? = params[key] else { return nil } + return value + } + + /// `v2OptionalTrimmedRawString`: trimmed raw string, `nil` when empty. + func optionalTrimmedRawString(_ params: [String: JSONValue], _ key: String) -> String? { + let trimmed = rawString(params, key)?.trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false) ? trimmed : nil + } + + /// `v2StringArray`: a JSON string array (trimmed, empties dropped); a single + /// trimmed non-empty string yields a one-element array; otherwise `nil`. + func stringArray(_ params: [String: JSONValue], _ key: String) -> [String]? { + if case .array(let raw)? = params[key] { + return raw.compactMap { element -> String? in + guard case .string(let value) = element else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + } + if let single = string(params, key) { + return [single] + } + return nil + } + + /// `v2StringMap`: a JSON object's string-valued entries, or `nil`. + func stringMap(_ params: [String: JSONValue], _ key: String) -> [String: String]? { + guard case .object(let raw)? = params[key] else { return nil } + var out: [String: String] = [:] + for (mapKey, value) in raw { + guard case .string(let stringValue) = value else { continue } + out[mapKey] = stringValue + } + return out + } + + /// `v2TrimmedStringMap`: the first present string-map among `keys`, with + /// trimmed non-empty keys; `[:]` when none present. + func trimmedStringMap(_ params: [String: JSONValue], keys: [String]) -> [String: String] { + for key in keys { + guard let raw = stringMap(params, key) else { continue } + return raw.reduce(into: [String: String]()) { result, pair in + let normalizedKey = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedKey.isEmpty else { return } + result[normalizedKey] = pair.value + } + } + return [:] + } + + /// `v2ActionKey`: a trimmed string lowercased with `-` mapped to `_`. + func actionKey(_ params: [String: JSONValue], _ key: String = "action") -> String? { + guard let action = string(params, key) else { return nil } + return action.lowercased().replacingOccurrences(of: "-", with: "_") + } + + /// `v2UUIDAny`: resolve a UUID (or `kind:N` ref) from a single JSON value. + func uuidAny(_ raw: JSONValue?) -> UUID? { + guard case .string(let value)? = raw else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let parsed = UUID(uuidString: trimmed) { + return parsed + } + return handles.uuid(forRef: trimmed) + } + + /// `v2Bool`: a JSON bool, a number (nonzero is true), or the + /// `1/true/yes/on` / `0/false/no/off` string set; otherwise `nil`. + func bool(_ params: [String: JSONValue], _ key: String) -> Bool? { + switch params[key] { + case .bool(let value): + return value + case .int(let value): + return value != 0 + case .double(let value): + return value != 0 + case .string(let value): + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "on": + return true + case "0", "false", "no", "off": + return false + default: + return nil + } + default: + return nil + } + } + + /// `v2Int`: a JSON int, a truncated double, or a parsable string. + func int(_ params: [String: JSONValue], _ key: String) -> Int? { + switch params[key] { + case .int(let value): + return Int(value) + case .double(let value): + return Int(value) + case .string(let value): + return Int(value) + default: + return nil + } + } + + /// `v2Double`: a JSON double, int, or parsable string. + func double(_ params: [String: JSONValue], _ key: String) -> Double? { + switch params[key] { + case .double(let value): + return value + case .int(let value): + return Double(value) + case .string(let value): + return Double(value) + default: + return nil + } + } + + /// `v2StrictInt`: an exact integer only — a non-boolean integral number or a + /// parsable integer string; fractional or non-finite numbers are rejected. + func strictInt(_ params: [String: JSONValue], _ key: String) -> Int? { + strictIntValue(params[key]) + } + + /// `v2StrictIntAny`: the strict-int rule for a single JSON value. + func strictIntValue(_ raw: JSONValue?) -> Int? { + switch raw { + case .int(let value): + return Int(value) + case .double(let value): + guard value.isFinite, floor(value) == value else { return nil } + return Int(exactly: value) + case .string(let value): + return Int(value.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + // .bool is rejected (legacy guarded CFBooleanGetTypeID); .null/.array + // /.object are non-numeric. + return nil + } + } + + /// `v2NormalizedToken`: lowercased with `-`, `_`, and spaces stripped. + func normalizedToken(_ raw: String) -> String { + raw.replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: " ", with: "") + .lowercased() + } + + /// `v2InitialDividerPosition`: optional clamped `[0.1, 0.9]` divider, or an + /// `invalid_params` error when present-but-non-numeric. + func initialDividerPosition( + _ params: [String: JSONValue] + ) -> (value: Double?, error: ControlCallResult?) { + guard hasNonNull(params, "initial_divider_position") else { + return (nil, nil) + } + guard let rawPosition = double(params, "initial_divider_position"), rawPosition.isFinite else { + return ( + nil, + .err(code: "invalid_params", message: "initial_divider_position must be numeric", data: nil) + ) + } + return (min(max(rawPosition, 0.1), 0.9), nil) + } + + // MARK: - Ref minting (typed twins of v2WorkspaceRefs / v2TabRef / …) + + /// `v2WorkspaceRefs`: stable workspace refs for a batch of ids. + func workspaceRefs(for ids: [UUID]) -> [UUID: String] { + var refs: [UUID: String] = [:] + refs.reserveCapacity(ids.count) + for id in ids { + refs[id] = handles.ensureRef(kind: .workspace, uuid: id) + } + return refs + } + + /// `v2WorkspacePaneAndSurfaceRefs`: the workspace/pane/surface ref triple. + func workspacePaneAndSurfaceRefs( + workspaceID: UUID, + paneID: UUID?, + surfaceID: UUID + ) -> (workspaceRef: String, paneRef: String?, surfaceRef: String) { + ( + workspaceRef: handles.ensureRef(kind: .workspace, uuid: workspaceID), + paneRef: paneID.map { handles.ensureRef(kind: .pane, uuid: $0) }, + surfaceRef: handles.ensureRef(kind: .surface, uuid: surfaceID) + ) + } + + /// `v2TabRef`: the legacy `tab:N` alias of a surface ref, or JSON `null`. + func tabRef(_ uuid: UUID?) -> JSONValue { + guard let uuid else { return .null } + let surfaceRef = handles.ensureRef(kind: .surface, uuid: uuid) + return .string(surfaceRef.replacingOccurrences(of: "surface:", with: "tab:")) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowContext.swift new file mode 100644 index 000000000000..c432d7c32455 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowContext.swift @@ -0,0 +1,71 @@ +public import Foundation + +/// The window-domain slice of the control-command seam (a constituent of the +/// ``ControlCommandContext`` umbrella). +/// +/// The app target (today `TerminalController`, the interim composition owner; +/// later `TerminalControlComposition`) conforms by reading `AppDelegate` / +/// `Workspace` / `TabManager` state. Every method is `@MainActor` because its +/// conformer lives on the main actor and the coordinator runs there too, so +/// these are plain in-isolation calls — the per-read `v2MainSync` hops the +/// legacy command bodies used disappear once a domain moves onto the +/// coordinator. +@MainActor +public protocol ControlWindowContext: AnyObject { + /// Snapshots every main window for `window.list`, in window order. + func controlWindowSummaries() -> [ControlWindowSummary] + + /// Resolves the window targeted by the given routing selectors for + /// `window.current`, mirroring the legacy `v2ResolveTabManager` → + /// `windowId(for:)` precedence and its two distinct failures. + /// + /// - Parameter routing: The pre-resolved routing selectors. + /// - Returns: The resolution outcome. + func controlResolveCurrentWindow(routing: ControlRoutingSelectors) -> ControlCurrentWindowResolution + + /// Focuses the window with the given id for `window.focus`. + /// + /// - Parameter id: The window to focus. + /// - Returns: Whether a matching window was found and focused. + func controlFocusWindow(id: UUID) -> Bool + + /// Creates a new main window and makes it the active tab-manager target for + /// `window.create` (create + defensive activation, as the legacy body did). + /// + /// - Returns: The new window's id, or `nil` if creation failed. + func controlCreateWindowAndActivate() -> UUID? + + /// Closes the window with the given id for `window.close`. + /// + /// - Parameter id: The window to close. + /// - Returns: Whether a matching window was found and closed. + func controlCloseWindow(id: UUID) -> Bool + + /// Snapshots every connected display for `window.displays`, in screen order. + func controlAvailableDisplays() -> [ControlDisplayInfo] + + /// Whether a window with the given id currently exists (for the + /// `window.display` not-found disambiguation). + /// + /// - Parameter id: The window id to test. + /// - Returns: Whether the window exists. + func controlWindowExists(id: UUID) -> Bool + + /// Moves one window onto the display matched by `query` for + /// `window.display`, preserving size. + /// + /// - Parameters: + /// - id: The window to move. + /// - query: The display match query (name, substring, or index). + /// - Returns: The resolved display name, or `nil` when the window or display + /// can't be resolved. + func controlMoveWindow(id: UUID, toDisplayMatching query: String) -> String? + + /// Moves every main window onto the display matched by `query` for + /// `window.display`, preserving sizes. + /// + /// - Parameter query: The display match query. + /// - Returns: The resolved display name and moved window ids, or `nil` when + /// the display can't be resolved. + func controlMoveAllWindows(toDisplayMatching query: String) -> ControlMoveAllWindowsResult? +} diff --git a/Sources/TerminalControllerControlCommandContext.swift b/Sources/TerminalControllerControlCommandContext.swift index e212acaa22d6..16ac62972322 100644 --- a/Sources/TerminalControllerControlCommandContext.swift +++ b/Sources/TerminalControllerControlCommandContext.swift @@ -7,11 +7,16 @@ import Foundation /// (which runs on main, inside the active `withSocketCommandPolicy` stack) can /// execute moved command domains without the package importing the app target. /// -/// These methods are the byte-faithful bodies of the former `v2Window*` -/// dispatchers, minus the per-read `v2MainSync` hop: the coordinator already -/// runs on the main actor inside the socket-command policy scope, so each hop -/// would re-apply the identical thread-local focus-allowance stack — a no-op. -extension TerminalController: ControlCommandContext { +/// `ControlCommandContext` is the umbrella; `TerminalController` satisfies it by +/// conforming to each domain constituent (one extension per domain file). The +/// umbrella conformance itself carries no requirements. +extension TerminalController: ControlCommandContext {} + +/// The window-domain witnesses are the byte-faithful bodies of the former +/// `v2Window*` dispatchers, minus the per-read `v2MainSync` hop: the coordinator +/// already runs on the main actor inside the socket-command policy scope, so each +/// hop would re-apply the identical thread-local focus-allowance stack — a no-op. +extension TerminalController: ControlWindowContext { func controlWindowSummaries() -> [ControlWindowSummary] { (AppDelegate.shared?.listMainWindowSummaries() ?? []).map { summary in ControlWindowSummary( From b02c28d2ae5f8d02dc1a4596e635f2c607e0eb70 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 14:00:29 -0700 Subject: [PATCH 03/21] stage 3c: extract App Focus + Feed + Notification domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the app-focus (app.focus_override.set, app.simulate_active), main-actor feed (feed.jump, feed.list), and notification (create/create_for_surface/create_for_target/ list/dismiss/mark_read/open/jump_to_unread/clear) domains into the coordinator behind their per-domain seams (ControlAppFocusContext/ControlFeedContext/ ControlNotificationContext), composed into the ControlCommandContext umbrella. The core handle(_:) now chains per-domain handleX dispatchers. Worker-lane methods stay app-side: feed.push/permission.reply/question.reply/ exit_plan.reply, and notification.create_for_caller (its own resolver). Faithfulness: byte-identical payloads/errors (live socket sweep on ctl3c1 confirms every result + error shape). Notification localized strings are resolved in the app conformance (app bundle) and passed through ControlNotificationStrings, because String(localized:) inside the package would bind to the package bundle and silently drop the Japanese translations — a wire change for non-English locales. Test fakes get benign defaults for non-window seams via ControlCommandContextTestStubs so each fake implements only the domain it exercises (128 package tests still green). TerminalController.swift 21952 -> 21522 (budget ratcheted). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 2 +- .../Coordinator/ControlAppFocusContext.swift | 26 ++ .../Coordinator/ControlCommandContext.swift | 8 +- .../ControlCommandCoordinator+AppFocus.swift | 67 +++ .../ControlCommandCoordinator+Feed.swift | 63 +++ ...ntrolCommandCoordinator+Notification.swift | 402 ++++++++++++++++ .../ControlCommandCoordinator+Window.swift | 23 + .../ControlCommandCoordinator.swift | 26 +- .../Coordinator/ControlFeedContext.swift | 35 ++ .../ControlNotificationContext.swift | 154 ++++++ .../ControlNotificationCreateResolution.swift | 24 + ...ControlNotificationDismissResolution.swift | 13 + ...ontrolNotificationMarkReadResolution.swift | 14 + .../ControlNotificationOpenResolution.swift | 17 + .../ControlNotificationSnapshot.swift | 70 +++ .../ControlNotificationStrings.swift | 56 +++ ...tificationTargetedDeliveryResolution.swift | 26 ++ .../ControlCommandContextTestStubs.swift | 71 +++ ...nalController+ControlAppFocusContext.swift | 20 + ...erminalController+ControlFeedContext.swift | 27 ++ ...ontroller+ControlNotificationContext.swift | 277 +++++++++++ Sources/TerminalController.swift | 440 +----------------- cmux.xcodeproj/project.pbxproj | 12 + 23 files changed, 1418 insertions(+), 455 deletions(-) create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlAppFocusContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+AppFocus.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Feed.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Notification.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlFeedContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationCreateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationDismissResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationMarkReadResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationOpenResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationStrings.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationTargetedDeliveryResolution.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift create mode 100644 Sources/TerminalController+ControlAppFocusContext.swift create mode 100644 Sources/TerminalController+ControlFeedContext.swift create mode 100644 Sources/TerminalController+ControlNotificationContext.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index ad6919806ed5..b5a1b005ec31 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,7 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 32655 CLI/cmux.swift -21952 Sources/TerminalController.swift +21522 Sources/TerminalController.swift 19820 Sources/Workspace.swift 19209 Sources/ContentView.swift 18011 Sources/AppDelegate.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlAppFocusContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlAppFocusContext.swift new file mode 100644 index 000000000000..bb6124d7e9e3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlAppFocusContext.swift @@ -0,0 +1,26 @@ +internal import Foundation + +/// The app-focus-domain slice of the control-command seam (a constituent of the +/// ``ControlCommandContext`` umbrella). +/// +/// The app target (today `TerminalController`, the interim composition owner) +/// conforms by reading and mutating the app's `AppFocusState` override and by +/// re-running the `applicationDidBecomeActive` activation path. Every method is +/// `@MainActor` because its conformer lives on the main actor and the +/// coordinator runs there too, so these are plain in-isolation calls — the +/// per-read `v2MainSync` hop the legacy `v2AppSimulateActive` body used +/// disappears once this domain moves onto the coordinator. +@MainActor +public protocol ControlAppFocusContext: AnyObject { + /// Sets (or clears) the app-focus override for `app.focus_override.set`, + /// mirroring the legacy `AppFocusState.overrideIsFocused = …` assignment. + /// + /// - Parameter focused: `true`/`false` to force the override, or `nil` to + /// clear it and fall back to the real `NSApp.isActive` state. + func controlSetAppFocusOverride(_ focused: Bool?) + + /// Re-runs the `applicationDidBecomeActive` activation path for + /// `app.simulate_active`, exactly as the legacy body did by posting an + /// `NSApplication.didBecomeActiveNotification` to `AppDelegate.shared`. + func controlSimulateAppActive() +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift index b0dd5cb5054c..54c9c509bc16 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift @@ -12,4 +12,10 @@ /// `AnyObject` so the coordinator can hold the conformer `weak` and avoid a /// retain cycle with its composition owner. @MainActor -public protocol ControlCommandContext: AnyObject, ControlWindowContext {} +public protocol ControlCommandContext: + AnyObject, + ControlWindowContext, + ControlAppFocusContext, + ControlFeedContext, + ControlNotificationContext +{} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+AppFocus.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+AppFocus.swift new file mode 100644 index 000000000000..c45b0b5f1ef1 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+AppFocus.swift @@ -0,0 +1,67 @@ +internal import Foundation + +/// The app-focus domain (`app.focus_override.set`, `app.simulate_active`), +/// lifted byte-faithfully from the former `TerminalController.v2AppFocusOverride` +/// / `v2AppSimulateActive` bodies. Each payload is built directly as a +/// ``JSONValue`` (the typed twin of the legacy `[String: Any]` dictionaries); +/// the resulting Foundation object is identical, so the encoded wire bytes match. +extension ControlCommandCoordinator { + /// Runs one decoded request if it belongs to the app-focus domain, returning + /// the typed result; returns `nil` otherwise so the core dispatcher falls + /// through to other domains. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not owned by this domain. + func handleAppFocus(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "app.focus_override.set": + return appFocusOverride(request.params) + case "app.simulate_active": + return appSimulateActive() + default: + return nil + } + } + + /// `app.focus_override.set` — force or clear the app-focus override. + /// + /// Accepts either a `state` of `active` / `inactive` / `clear` (also `none`), + /// or a `focused` boolean (present-but-non-boolean, or explicit `null`, + /// clears). The two acceptance paths and their error shapes are byte-faithful + /// to the legacy body. + func appFocusOverride(_ params: [String: JSONValue]) -> ControlCallResult { + let override: Bool? + if let state = string(params, "state")?.lowercased() { + switch state { + case "active": + override = true + case "inactive": + override = false + case "clear", "none": + override = nil + default: + return .err( + code: "invalid_params", + message: "Invalid state (active|inactive|clear)", + data: .object(["state": .string(state)]) + ) + } + } else if params.keys.contains("focused") { + // Legacy: `params.keys.contains("focused")` — present (including an + // explicit JSON null) routes here; a non-boolean or null value + // clears the override. + override = bool(params, "focused") + } else { + return .err(code: "invalid_params", message: "Missing state or focused", data: nil) + } + + context?.controlSetAppFocusOverride(override) + return .ok(.object(["override": override.map { JSONValue.bool($0) } ?? .null])) + } + + /// `app.simulate_active` — re-run the app's `applicationDidBecomeActive` path. + func appSimulateActive() -> ControlCallResult { + context?.controlSimulateAppActive() + return .ok(.object([:])) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Feed.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Feed.swift new file mode 100644 index 000000000000..eb1c3d53d221 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Feed.swift @@ -0,0 +1,63 @@ +internal import Foundation + +/// The main-actor feed domain (`feed.jump`, `feed.list`), lifted byte-faithfully +/// from the former `TerminalController.v2Feed*` bodies. Each payload is built +/// directly as a ``JSONValue`` (the typed twin of the legacy `[String: Any]` +/// dictionaries); the resulting Foundation object is identical, so the encoded +/// wire bytes match. +/// +/// The worker-lane feed methods (`feed.push`, `feed.permission.reply`, +/// `feed.question.reply`, `feed.exit_plan.reply`) block or await on the socket +/// worker and remain on the app-side worker path — they are deliberately NOT +/// dispatched here. +extension ControlCommandCoordinator { + /// Dispatches the feed methods this coordinator owns; returns `nil` for + /// anything else so the core `handle(_:)` can fall through. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a feed method. + func handleFeed(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "feed.jump": + return feedJump(request.params) + case "feed.list": + return feedList(request.params) + default: + return nil + } + } + + /// `feed.jump` — resolve whether a workstream id maps to a known surface. + func feedJump(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workstreamID = rawString(params, "workstream_id") else { + return .err( + code: "invalid_params", + message: "feed.jump requires workstream_id", + data: nil + ) + } + // MVP: resolve to a cmux surface via `SessionIndexStore` lands in + // the UI PR; for now we return whether the id is known so callers + // can show a toast. + let matched = context?.controlFeedResolvePossibleSurface(workstreamID: workstreamID) ?? false + return .ok(.object([ + "workstream_id": .string(workstreamID), + "matched": .bool(matched), + ])) + } + + /// `feed.list` — snapshot the workstream feed items. + func feedList(_ params: [String: JSONValue]) -> ControlCallResult { + // Legacy used a plain `params["pending_only"] as? Bool`, so only a real + // JSON boolean counts; anything else (including coercible strings/numbers) + // falls back to `false`. + let pendingOnly: Bool + if case .bool(let value)? = params["pending_only"] { + pendingOnly = value + } else { + pendingOnly = false + } + let items = context?.controlFeedSnapshotItems(pendingOnly: pendingOnly) ?? [] + return .ok(.object(["items": .array(items)])) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Notification.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Notification.swift new file mode 100644 index 000000000000..0b9010d18da7 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Notification.swift @@ -0,0 +1,402 @@ +internal import Foundation + +/// The notification domain (`notification.*`), lifted byte-faithfully from the +/// former `TerminalController.v2Notification*` bodies. Each payload is built +/// directly as a ``JSONValue`` (the typed twin of the legacy `[String: Any]` +/// dictionaries); the resulting Foundation object is identical, so the encoded +/// wire bytes match. The `notification.create_for_caller` method is NOT here: +/// it has its own self-contained app-side resolver and is left in place. +extension ControlCommandCoordinator { + /// Runs one decoded request if it belongs to the notification domain, + /// returning the typed result; returns `nil` otherwise so the caller can + /// fall through. The integrator calls this from the core `handle`. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a notification method. + func handleNotification(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "notification.create": + return notificationCreate(request.params) + case "notification.create_for_surface": + return notificationCreateForSurface(request.params) + case "notification.create_for_target": + return notificationCreateForTarget(request.params) + case "notification.list": + return notificationList() + case "notification.clear": + return notificationClear() + case "notification.dismiss": + return notificationDismiss(request.params) + case "notification.mark_read": + return notificationMarkRead(request.params) + case "notification.open": + return notificationOpen(request.params) + case "notification.jump_to_unread": + return notificationJumpToUnread() + default: + return nil + } + } + + // MARK: - Create + + /// `notification.create` — deliver to the resolved/focused surface. + func notificationCreate(_ params: [String: JSONValue]) -> ControlCallResult { + let title = rawString(params, "title") ?? "Notification" + let subtitle = rawString(params, "subtitle") ?? "" + let body = rawString(params, "body") ?? "" + let resolution = context?.controlNotificationCreate( + routing: routingSelectors(params), + explicitSurfaceID: uuid(params, "surface_id"), + title: title, + subtitle: subtitle, + body: body + ) ?? .tabManagerUnavailable + + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .surfaceNotFound(let surfaceID): + return .err( + code: "not_found", + message: "Surface not found", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .delivered(let workspaceID, let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "surface_id": orNull(surfaceID?.uuidString), + ])) + } + } + + /// `notification.create_for_surface` — deliver to a required surface in the + /// resolved workspace, echoing the workspace/surface/window identity. + func notificationCreateForSurface(_ params: [String: JSONValue]) -> ControlCallResult { + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let title = rawString(params, "title") ?? "Notification" + let subtitle = rawString(params, "subtitle") ?? "" + let body = rawString(params, "body") ?? "" + let resolution = context?.controlNotificationCreateForSurface( + routing: routingSelectors(params), + surfaceID: surfaceID, + title: title, + subtitle: subtitle, + body: body + ) ?? .tabManagerUnavailable + return targetedDeliveryResult(resolution) + } + + /// `notification.create_for_target` — deliver to a required workspace + + /// surface, echoing the workspace/surface/window identity. + func notificationCreateForTarget(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let title = rawString(params, "title") ?? "Notification" + let subtitle = rawString(params, "subtitle") ?? "" + let body = rawString(params, "body") ?? "" + let resolution = context?.controlNotificationCreateForTarget( + routing: routingSelectors(params), + workspaceID: workspaceID, + surfaceID: surfaceID, + title: title, + subtitle: subtitle, + body: body + ) ?? .tabManagerUnavailable + return targetedDeliveryResult(resolution) + } + + /// The shared result shaping for `create_for_surface` / `create_for_target`. + private func targetedDeliveryResult( + _ resolution: ControlNotificationTargetedDeliveryResolution + ) -> ControlCallResult { + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound(let workspaceID): + let data: JSONValue? = workspaceID.map { .object(["workspace_id": .string($0.uuidString)]) } + return .err(code: "not_found", message: "Workspace not found", data: data) + case .surfaceNotFound(let surfaceID): + return .err( + code: "not_found", + message: "Surface not found", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .delivered(let workspaceID, let surfaceID, let windowID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - List / clear + + /// `notification.list` — every notification, with read state. + func notificationList() -> ControlCallResult { + let items = (context?.controlNotificationList() ?? []).map { + notificationPayload($0, opened: nil, includeReadState: true) + } + return .ok(.object(["notifications": .array(items)])) + } + + /// `notification.clear` — enqueue clearing all notifications. + func notificationClear() -> ControlCallResult { + context?.controlNotificationClear() + return .ok(.object([:])) + } + + // MARK: - Dismiss + + /// `notification.dismiss` — remove one notification by id, or every read one. + func notificationDismiss(_ params: [String: JSONValue]) -> ControlCallResult { + let id = uuid(params, "id") + let allRead = bool(params, "all_read") ?? false + let selectorCount = (id == nil ? 0 : 1) + (allRead ? 1 : 0) + + guard selectorCount == 1 else { + return .err( + code: "invalid_params", + message: notificationDismissSelectorRequiredMessage, + data: nil + ) + } + + if allRead { + let dismissedCount = context?.controlNotificationDismissAllRead() ?? 0 + return .ok(.object([ + "dismissed": .int(Int64(dismissedCount)), + "all_read": .bool(true), + ])) + } + + guard let id else { + return .err( + code: "invalid_params", + message: notificationIDRequiredMessage, + data: nil + ) + } + + let resolution = context?.controlNotificationDismiss(id: id) ?? .notFound + switch resolution { + case .notFound: + return .err( + code: "not_found", + message: notificationNotFoundMessage, + data: .object(["id": .string(id.uuidString)]) + ) + case .dismissed(let snapshot): + var payload = notificationPayloadObject(snapshot, opened: nil, includeReadState: true) + payload["dismissed"] = .int(1) + return .ok(.object(payload)) + } + } + + // MARK: - Mark read + + /// `notification.mark_read` — mark one notification, a workspace's, or all. + func notificationMarkRead(_ params: [String: JSONValue]) -> ControlCallResult { + let id = uuid(params, "id") + let tabID = uuid(params, "tab_id") ?? uuid(params, "workspace_id") + let hasSurfaceSelector = hasNonNull(params, "surface_id") + let surfaceID = uuid(params, "surface_id") + let all = bool(params, "all") ?? false + let selectorCount = (id == nil ? 0 : 1) + (tabID == nil ? 0 : 1) + (all ? 1 : 0) + + guard selectorCount == 1 else { + return .err( + code: "invalid_params", + message: notificationMarkReadSelectorRequiredMessage, + data: nil + ) + } + if hasSurfaceSelector, surfaceID == nil { + return .err( + code: "invalid_params", + message: notificationSurfaceIDInvalidMessage, + data: nil + ) + } + if hasSurfaceSelector, tabID == nil { + return .err( + code: "invalid_params", + message: notificationSurfaceIDRequiresWorkspaceMessage, + data: nil + ) + } + + let markedCount: Int + if let id { + let resolution = context?.controlNotificationMarkRead(id: id) ?? .notFound + switch resolution { + case .notFound: + return .err( + code: "not_found", + message: notificationNotFoundMessage, + data: .object(["id": .string(id.uuidString)]) + ) + case .marked(let count): + markedCount = count + } + } else if let tabID { + markedCount = context?.controlNotificationMarkRead( + workspaceID: tabID, + surfaceID: surfaceID, + hasSurfaceSelector: hasSurfaceSelector + ) ?? 0 + } else { + // `all` is the only remaining selector (selectorCount == 1). + markedCount = context?.controlNotificationMarkReadAll() ?? 0 + } + + var result: [String: JSONValue] = ["marked_read": .int(Int64(markedCount))] + if let id { result["id"] = .string(id.uuidString) } + if let tabID { + result["workspace_id"] = .string(tabID.uuidString) + result["workspace_ref"] = ref(.workspace, tabID) + } + if hasSurfaceSelector { + result["surface_id"] = orNull(surfaceID?.uuidString) + result["surface_ref"] = ref(.surface, surfaceID) + } + if all { result["all"] = .bool(true) } + return .ok(.object(result)) + } + + // MARK: - Open / jump + + /// `notification.open` — open one notification's target. + func notificationOpen(_ params: [String: JSONValue]) -> ControlCallResult { + guard let id = uuid(params, "id") else { + return .err( + code: "invalid_params", + message: notificationIDRequiredMessage, + data: nil + ) + } + let resolution = context?.controlNotificationOpen(id: id) ?? .notificationNotFound + switch resolution { + case .notificationNotFound: + return .err( + code: "not_found", + message: notificationNotFoundMessage, + data: .object(["id": .string(id.uuidString)]) + ) + case .targetNotFound(let snapshot): + return .err( + code: "not_found", + message: notificationTargetNotFoundMessage, + data: notificationPayload(snapshot, opened: false, includeReadState: true) + ) + case .opened(let snapshot): + return .ok(notificationPayload(snapshot, opened: true, includeReadState: true)) + } + } + + /// `notification.jump_to_unread` — open the latest unread notification. + func notificationJumpToUnread() -> ControlCallResult { + guard let snapshot = context?.controlNotificationJumpToUnread() else { + return .ok(.object(["opened": .bool(false)])) + } + return .ok(notificationPayload(snapshot, opened: true, includeReadState: true)) + } + + // MARK: - Payload builder (typed twin of TerminalController.notificationPayload) + + /// Builds the notification payload object, byte-faithful to the legacy + /// `notificationPayload(_:opened:includeReadState:)`. + private func notificationPayload( + _ snapshot: ControlNotificationSnapshot, + opened: Bool?, + includeReadState: Bool + ) -> JSONValue { + .object(notificationPayloadObject(snapshot, opened: opened, includeReadState: includeReadState)) + } + + /// The mutable dictionary form, so `dismiss` can append `dismissed` exactly + /// as the legacy body did before wrapping in `.ok`. + private func notificationPayloadObject( + _ snapshot: ControlNotificationSnapshot, + opened: Bool?, + includeReadState: Bool + ) -> [String: JSONValue] { + var payload: [String: JSONValue] = [ + "id": .string(snapshot.id.uuidString), + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "surface_id": orNull(snapshot.surfaceID?.uuidString), + "surface_ref": ref(.surface, snapshot.surfaceID), + "title": .string(snapshot.title), + "subtitle": .string(snapshot.subtitle), + "body": .string(snapshot.body), + "created_at": .string(snapshot.createdAtISO8601), + "tab_title": orNull(snapshot.tabTitle), + ] + if includeReadState { + payload["is_read"] = .bool(snapshot.isRead) + } + if let opened { + payload["opened"] = .bool(opened) + } + return payload + } + + // MARK: - Localized error messages + + /// The localized notification messages from the app conformance, or the + /// English defaults when no context is wired (the latter only happens + /// pre-wiring; production always has a context). Keys/default values are + /// identical to the legacy `String(localized:)` calls. + private var notificationStrings: ControlNotificationStrings { + context?.notificationStrings ?? ControlNotificationStrings( + dismissSelectorRequired: "Select exactly one of id or all_read", + idRequired: "Missing or invalid notification id", + notFound: "Notification not found", + markReadSelectorRequired: "Select exactly one of id, tab_id, or all", + surfaceIDInvalid: "Missing or invalid surface_id", + surfaceIDRequiresWorkspace: "surface_id requires tab_id or workspace_id", + targetNotFound: "Notification target not found" + ) + } + + private var notificationDismissSelectorRequiredMessage: String { + notificationStrings.dismissSelectorRequired + } + + private var notificationIDRequiredMessage: String { + notificationStrings.idRequired + } + + private var notificationNotFoundMessage: String { + notificationStrings.notFound + } + + private var notificationMarkReadSelectorRequiredMessage: String { + notificationStrings.markReadSelectorRequired + } + + private var notificationSurfaceIDInvalidMessage: String { + notificationStrings.surfaceIDInvalid + } + + private var notificationSurfaceIDRequiresWorkspaceMessage: String { + notificationStrings.surfaceIDRequiresWorkspace + } + + private var notificationTargetNotFoundMessage: String { + notificationStrings.targetNotFound + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift index 1988b14df097..6e16a39e4533 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift @@ -6,6 +6,29 @@ internal import Foundation /// the resulting Foundation object is identical, so the encoded wire bytes /// match. extension ControlCommandCoordinator { + /// Dispatches the window methods this coordinator owns; returns `nil` for + /// anything else so the core `handle(_:)` can fall through. + func handleWindow(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "window.list": + return windowList() + case "window.current": + return windowCurrent(request.params) + case "window.focus": + return windowFocus(request.params) + case "window.create": + return windowCreate() + case "window.close": + return windowClose(request.params) + case "window.displays": + return windowDisplays() + case "window.display": + return windowDisplay(request.params) + default: + return nil + } + } + /// `window.list` — every main window, in order. func windowList() -> ControlCallResult { let windows = context?.controlWindowSummaries() ?? [] diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift index f34e0f1cbea1..7096fc1c28e6 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift @@ -60,24 +60,14 @@ public final class ControlCommandCoordinator { /// - Parameter request: The decoded request envelope. /// - Returns: The command result, or `nil` if not owned here. public func handle(_ request: ControlRequest) -> ControlCallResult? { - switch request.method { - case "window.list": - return windowList() - case "window.current": - return windowCurrent(request.params) - case "window.focus": - return windowFocus(request.params) - case "window.create": - return windowCreate() - case "window.close": - return windowClose(request.params) - case "window.displays": - return windowDisplays() - case "window.display": - return windowDisplay(request.params) - default: - return nil - } + // Each domain's handler (in its own `+.swift` extension) owns its + // methods and returns `nil` for anything else, so the chain falls through + // to the next domain and finally to the legacy app-side dispatcher. + if let result = handleWindow(request) { return result } + if let result = handleAppFocus(request) { return result } + if let result = handleFeed(request) { return result } + if let result = handleNotification(request) { return result } + return nil } // MARK: - Handle registry (shared ref minting) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlFeedContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlFeedContext.swift new file mode 100644 index 000000000000..db13afd373e1 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlFeedContext.swift @@ -0,0 +1,35 @@ +/// The feed-domain (workstream) slice of the control-command seam (a constituent +/// of the ``ControlCommandContext`` umbrella). +/// +/// Covers only the MAIN-ACTOR feed methods (`feed.jump`, `feed.list`). The +/// worker-lane feed methods (`feed.push`, `feed.permission.reply`, +/// `feed.question.reply`, `feed.exit_plan.reply`) block or await on the socket +/// worker and stay on the app-side worker path; they are NOT part of this seam. +/// +/// The app target (today `TerminalController`, the interim composition owner; +/// later `TerminalControlComposition`) conforms by reaching `FeedCoordinator` +/// state. Every method is `@MainActor` because its conformer lives on the main +/// actor and the coordinator runs there too, so these are plain in-isolation +/// calls — the per-read `v2MainSync` hops the legacy command bodies used +/// disappear once the domain moves onto the coordinator. +@MainActor +public protocol ControlFeedContext: AnyObject { + /// Resolves whether a workstream id maps to a known cmux surface for + /// `feed.jump`, mirroring the legacy `FeedCoordinator.resolvePossibleSurface` + /// probe (the MVP returns only whether the id is known so callers can show a + /// toast). + /// + /// - Parameter workstreamID: The caller-supplied `workstream_id`, untrimmed, + /// exactly as the legacy body forwarded it. + /// - Returns: Whether the id matched a possible surface. + func controlFeedResolvePossibleSurface(workstreamID: String) -> Bool + + /// Snapshots the workstream feed items for `feed.list`, already shaped as the + /// per-item JSON the legacy `FeedSocketEncoding.itemDict` produced and bridged + /// to ``JSONValue`` so the encoded wire bytes match. + /// + /// - Parameter pendingOnly: When `true`, only pending items are returned + /// (mirrors the legacy `pending_only` filter on `FeedCoordinator.snapshot`). + /// - Returns: The feed items as JSON values, in snapshot order. + func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationContext.swift new file mode 100644 index 000000000000..a7468746ce73 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationContext.swift @@ -0,0 +1,154 @@ +public import Foundation + +/// The notification-domain slice of the control-command seam (a constituent of +/// the ``ControlCommandContext`` umbrella). +/// +/// The app target (today `TerminalController`, the interim composition owner) +/// conforms by reaching the `TerminalNotificationStore`, `AppDelegate`, and +/// workspace/surface resolution. Every method is `@MainActor` because its +/// conformer and the coordinator both live on the main actor, so these are +/// plain in-isolation calls — the per-read `v2MainSync` hops the legacy command +/// bodies used disappear once the domain moves onto the coordinator. +/// +/// No app types cross the seam: deliveries take pre-parsed selectors/ids and +/// return small Sendable resolution enums, and reads return +/// ``ControlNotificationSnapshot`` values. +@MainActor +public protocol ControlNotificationContext: AnyObject { + /// Delivers a notification for `notification.create`: resolves the + /// TabManager and workspace from `routing`, optionally validating + /// `explicitSurfaceID`, then delivers to that surface or the workspace's + /// focused surface. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager + workspace + /// resolution (legacy `v2ResolveTabManager` / `v2ResolveWorkspace`). + /// - explicitSurfaceID: The explicit `surface_id` to validate and target, + /// if the request carried one. Validation uses only this strict + /// `surface_id` (not the `terminal_id`/`tab_id` aliases). + /// - title: The notification title. + /// - subtitle: The notification subtitle. + /// - body: The notification body. + /// - Returns: The delivery resolution. + func controlNotificationCreate( + routing: ControlRoutingSelectors, + explicitSurfaceID: UUID?, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationCreateResolution + + /// Delivers a notification for `notification.create_for_surface`: resolves + /// the TabManager and workspace from `routing`, requires `surfaceID` to + /// exist in that workspace, then delivers and echoes the workspace/surface/ + /// window identity. + /// + /// - Parameters: + /// - routing: The routing selectors for TabManager + workspace resolution. + /// - surfaceID: The required target surface. + /// - title: The notification title. + /// - subtitle: The notification subtitle. + /// - body: The notification body. + /// - Returns: The targeted delivery resolution (workspace-not-found carries + /// `nil`, matching the legacy `data: nil`). + func controlNotificationCreateForSurface( + routing: ControlRoutingSelectors, + surfaceID: UUID, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationTargetedDeliveryResolution + + /// Delivers a notification for `notification.create_for_target`: resolves + /// the TabManager from `routing`, finds the workspace `workspaceID` within + /// it, requires `surfaceID` to exist there, then delivers and echoes the + /// workspace/surface/window identity. + /// + /// - Parameters: + /// - routing: The routing selectors for TabManager resolution. + /// - workspaceID: The required target workspace (looked up in the resolved + /// TabManager's tabs). + /// - surfaceID: The required target surface. + /// - title: The notification title. + /// - subtitle: The notification subtitle. + /// - body: The notification body. + /// - Returns: The targeted delivery resolution (workspace-not-found carries + /// `workspaceID`, matching the legacy `data.workspace_id`). + func controlNotificationCreateForTarget( + routing: ControlRoutingSelectors, + workspaceID: UUID, + surfaceID: UUID, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationTargetedDeliveryResolution + + /// Snapshots every notification for `notification.list`, in store order, + /// with read state included. + func controlNotificationList() -> [ControlNotificationSnapshot] + + /// Removes every read notification for `notification.dismiss` with + /// `all_read`. + /// + /// - Returns: How many notifications were removed. + func controlNotificationDismissAllRead() -> Int + + /// Removes the notification with the given id for `notification.dismiss` + /// with an `id` selector. + /// + /// - Parameter id: The notification to dismiss. + /// - Returns: The dismiss resolution (the pre-removal snapshot on success). + func controlNotificationDismiss(id: UUID) -> ControlNotificationDismissResolution + + /// Marks the notification with the given id read for `notification.mark_read` + /// with an `id` selector. + /// + /// - Parameter id: The notification to mark read. + /// - Returns: The mark-read resolution. + func controlNotificationMarkRead(id: UUID) -> ControlNotificationMarkReadResolution + + /// Marks notifications read for `notification.mark_read` with a workspace + /// selector (`tab_id`/`workspace_id`), optionally scoped to a surface. + /// + /// - Parameters: + /// - workspaceID: The workspace whose notifications to mark read. + /// - surfaceID: The surface to scope to, when `hasSurfaceSelector` is true. + /// - hasSurfaceSelector: Whether the request carried a `surface_id` + /// selector (the legacy `surfaceId`-aware vs workspace-wide branch). + /// - Returns: How many notifications flipped from unread to read. + func controlNotificationMarkRead( + workspaceID: UUID, + surfaceID: UUID?, + hasSurfaceSelector: Bool + ) -> Int + + /// Marks every notification read for `notification.mark_read` with `all`. + /// + /// - Returns: How many notifications flipped from unread to read. + func controlNotificationMarkReadAll() -> Int + + /// Opens the target of the notification with the given id for + /// `notification.open`, re-reading the (possibly mutated) notification for + /// the response. + /// + /// - Parameter id: The notification to open. + /// - Returns: The open resolution. + func controlNotificationOpen(id: UUID) -> ControlNotificationOpenResolution + + /// Jumps to and opens the latest unread notification for + /// `notification.jump_to_unread`. + /// + /// - Returns: The opened notification's snapshot, or `nil` when there was + /// nothing unread to open. + func controlNotificationJumpToUnread() -> ControlNotificationSnapshot? + + /// Enqueues clearing all notifications for `notification.clear`. + func controlNotificationClear() + + /// The localized notification-domain error strings, resolved against the + /// app's `Localizable.xcstrings` (the package bundle lacks these keys, so + /// the coordinator must not call `String(localized:)` itself — that would + /// drop non-English localizations). The app conformance supplies them with + /// the identical keys and default values the legacy bodies used. + var notificationStrings: ControlNotificationStrings { get } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationCreateResolution.swift new file mode 100644 index 000000000000..c68242c7b35d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationCreateResolution.swift @@ -0,0 +1,24 @@ +public import Foundation + +/// The outcome of `notification.create`, preserving the legacy body's three +/// distinct failures and the delivered identity it echoes back. +/// +/// The legacy body resolved a TabManager from the routing params, then a +/// workspace, optionally validated an explicit `surface_id`, then delivered to +/// the explicit surface or the workspace's focused surface. +public enum ControlNotificationCreateResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// A TabManager resolved but no workspace did (legacy `not_found` / + /// "Workspace not found", `data: nil`). + case workspaceNotFound + /// An explicit `surface_id` was given but the resolved workspace has no + /// such surface (legacy `not_found` / "Surface not found", `data: + /// {"surface_id": …}`). Carries the unresolved surface id. + case surfaceNotFound(UUID) + /// The notification was delivered. Carries the workspace id and the surface + /// it landed on (the explicit surface, or the workspace's focused surface, + /// which may be absent). + case delivered(workspaceID: UUID, surfaceID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationDismissResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationDismissResolution.swift new file mode 100644 index 000000000000..c51f40bdfac9 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationDismissResolution.swift @@ -0,0 +1,13 @@ +/// The outcome of dismissing a single notification by id (`notification.dismiss` +/// with an `id` selector). +/// +/// The legacy body captured the notification's payload before removing it, or +/// reported not-found when no notification matched. +public enum ControlNotificationDismissResolution: Sendable, Equatable { + /// No notification with the requested id existed (legacy `not_found` / + /// "Notification not found", `data: {"id": …}`). + case notFound + /// The notification was removed. Carries the pre-removal snapshot used to + /// build the success payload. + case dismissed(ControlNotificationSnapshot) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationMarkReadResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationMarkReadResolution.swift new file mode 100644 index 000000000000..3c9f0667c5f7 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationMarkReadResolution.swift @@ -0,0 +1,14 @@ +/// The outcome of marking a single notification read by id (`notification.mark_read` +/// with an `id` selector). +/// +/// The legacy body required the notification to exist before marking, reporting +/// not-found otherwise, and otherwise reported how many notifications flipped +/// from unread to read. +public enum ControlNotificationMarkReadResolution: Sendable, Equatable { + /// No notification with the requested id existed (legacy `not_found` / + /// "Notification not found", `data: {"id": …}`). + case notFound + /// The notification existed. Carries the count of notifications that flipped + /// from unread to read (the legacy `marked_read`). + case marked(count: Int) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationOpenResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationOpenResolution.swift new file mode 100644 index 000000000000..6ff0fdba7ac7 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationOpenResolution.swift @@ -0,0 +1,17 @@ +/// The outcome of `notification.open`, preserving the legacy body's two +/// distinct `not_found` failures. +/// +/// The legacy body looked up the notification, asked the app to open its +/// target, then re-read the (possibly mutated, e.g. now-read) notification for +/// the response payload. +public enum ControlNotificationOpenResolution: Sendable, Equatable { + /// No notification with the requested id exists (legacy `not_found` / + /// "Notification not found", `data: {"id": …}`). + case notificationNotFound + /// The notification exists but its target could not be opened (legacy + /// `not_found` / "Notification target not found", `data:` the payload). + /// Carries the post-open snapshot used to build that payload. + case targetNotFound(ControlNotificationSnapshot) + /// The notification's target was opened. Carries the post-open snapshot. + case opened(ControlNotificationSnapshot) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationSnapshot.swift new file mode 100644 index 000000000000..291dde77c3c1 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationSnapshot.swift @@ -0,0 +1,70 @@ +public import Foundation + +/// A read-only snapshot of one delivered terminal notification, as the app +/// target exposes it to ``ControlCommandCoordinator`` through +/// ``ControlNotificationContext``. +/// +/// Mirrors the app target's `TerminalNotification` (plus the two app-resolved +/// adornments the legacy `notificationPayload` builder added: the ISO-8601 +/// `createdAt` rendering and the workspace's tab title) without the package +/// importing the app target. The coordinator turns each snapshot into a +/// notification payload object, byte-identically to the former +/// `[String: Any]` builder. +public struct ControlNotificationSnapshot: Sendable, Equatable { + /// The notification's stable identifier. + public let id: UUID + /// The workspace (tab) the notification belongs to. + public let workspaceID: UUID + /// The surface the notification targets, if any. + public let surfaceID: UUID? + /// The notification title. + public let title: String + /// The notification subtitle. + public let subtitle: String + /// The notification body. + public let body: String + /// The creation timestamp pre-rendered exactly as the legacy + /// `notificationCreatedAtString` did (`ISO8601DateFormatter` with + /// `.withInternetDateTime`, GMT). Carried as a string so the package never + /// re-formats the date and the wire bytes stay identical. + public let createdAtISO8601: String + /// Whether the notification has been marked read. + public let isRead: Bool + /// The workspace's tab title, if the app could resolve one (the legacy + /// `AppDelegate.tabTitle(for:)` read, written as `tab_title`). + public let tabTitle: String? + + /// Creates a notification snapshot. + /// + /// - Parameters: + /// - id: The notification's stable identifier. + /// - workspaceID: The owning workspace (tab) id. + /// - surfaceID: The targeted surface, if any. + /// - title: The notification title. + /// - subtitle: The notification subtitle. + /// - body: The notification body. + /// - createdAtISO8601: The pre-rendered ISO-8601 creation timestamp. + /// - isRead: Whether the notification is read. + /// - tabTitle: The owning workspace's tab title, if any. + public init( + id: UUID, + workspaceID: UUID, + surfaceID: UUID?, + title: String, + subtitle: String, + body: String, + createdAtISO8601: String, + isRead: Bool, + tabTitle: String? + ) { + self.id = id + self.workspaceID = workspaceID + self.surfaceID = surfaceID + self.title = title + self.subtitle = subtitle + self.body = body + self.createdAtISO8601 = createdAtISO8601 + self.isRead = isRead + self.tabTitle = tabTitle + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationStrings.swift new file mode 100644 index 000000000000..6f783438e835 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationStrings.swift @@ -0,0 +1,56 @@ +/// The localized notification-domain error messages, supplied by the app +/// conformance so they resolve against the app's `Localizable.xcstrings`. +/// +/// The coordinator builds the error envelopes (it owns the selector +/// validation), but the strings must keep their existing keys + default values +/// and their per-locale translations. Resolving `String(localized:)` inside the +/// package would bind to the package bundle, which lacks these keys, silently +/// dropping the non-English variants — so the app passes the already-resolved +/// strings across the seam instead. +public struct ControlNotificationStrings: Sendable, Equatable { + /// `socket.notification.dismissSelectorRequired` — + /// "Select exactly one of id or all_read". + public let dismissSelectorRequired: String + /// `socket.notification.idRequired` — "Missing or invalid notification id". + public let idRequired: String + /// `socket.notification.notFound` — "Notification not found". + public let notFound: String + /// `socket.notification.markReadSelectorRequired` — + /// "Select exactly one of id, tab_id, or all". + public let markReadSelectorRequired: String + /// `socket.notification.surfaceIdInvalid` — "Missing or invalid surface_id". + public let surfaceIDInvalid: String + /// `socket.notification.surfaceIdRequiresWorkspace` — + /// "surface_id requires tab_id or workspace_id". + public let surfaceIDRequiresWorkspace: String + /// `socket.notification.targetNotFound` — "Notification target not found". + public let targetNotFound: String + + /// Creates the localized message bundle. + /// + /// - Parameters: + /// - dismissSelectorRequired: The dismiss-selector-required message. + /// - idRequired: The id-required message. + /// - notFound: The notification-not-found message. + /// - markReadSelectorRequired: The mark-read-selector-required message. + /// - surfaceIDInvalid: The invalid-surface_id message. + /// - surfaceIDRequiresWorkspace: The surface_id-requires-workspace message. + /// - targetNotFound: The target-not-found message. + public init( + dismissSelectorRequired: String, + idRequired: String, + notFound: String, + markReadSelectorRequired: String, + surfaceIDInvalid: String, + surfaceIDRequiresWorkspace: String, + targetNotFound: String + ) { + self.dismissSelectorRequired = dismissSelectorRequired + self.idRequired = idRequired + self.notFound = notFound + self.markReadSelectorRequired = markReadSelectorRequired + self.surfaceIDInvalid = surfaceIDInvalid + self.surfaceIDRequiresWorkspace = surfaceIDRequiresWorkspace + self.targetNotFound = targetNotFound + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationTargetedDeliveryResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationTargetedDeliveryResolution.swift new file mode 100644 index 000000000000..a633fd9540d3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationTargetedDeliveryResolution.swift @@ -0,0 +1,26 @@ +public import Foundation + +/// The outcome of a targeted notification delivery (`notification.create_for_surface` +/// and `notification.create_for_target`), preserving the legacy bodies' +/// failures and the rich identity payload they echo back (workspace, surface, +/// and window refs). +/// +/// The two callers differ only in their workspace-not-found error detail: +/// `create_for_surface` returns no data, `create_for_target` returns the +/// requested `workspace_id`. That difference is carried in +/// ``workspaceNotFound(workspaceID:)`` so one resolution type serves both. +public enum ControlNotificationTargetedDeliveryResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). The + /// associated id, when non-`nil`, becomes the error `data.workspace_id` + /// (`create_for_target`); `nil` yields `data: nil` (`create_for_surface`). + case workspaceNotFound(workspaceID: UUID?) + /// The resolved workspace has no such surface (legacy `not_found` / + /// "Surface not found", `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotFound(UUID) + /// The notification was delivered. Carries the resolved workspace id, the + /// target surface id, and the resolved window id (which may be absent). + case delivered(workspaceID: UUID, surfaceID: UUID, windowID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift new file mode 100644 index 000000000000..35cc8d58a196 --- /dev/null +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import CmuxControlSocket + +// Benign default implementations of the non-window domain seams, so a test fake +// that conforms to the full `ControlCommandContext` umbrella only has to +// implement the domain it actually exercises. Each domain's own tests override +// the methods they drive; everything else returns an inert "nothing here" +// result. As domains land, add their defaults here (one block per domain). + +extension ControlAppFocusContext { + func controlSetAppFocusOverride(_ focused: Bool?) {} + func controlSimulateAppActive() {} +} + +extension ControlFeedContext { + func controlFeedResolvePossibleSurface(workstreamID: String) -> Bool { false } + func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] { [] } +} + +extension ControlNotificationContext { + func controlNotificationCreate( + routing: ControlRoutingSelectors, + explicitSurfaceID: UUID?, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationCreateResolution { .tabManagerUnavailable } + + func controlNotificationCreateForSurface( + routing: ControlRoutingSelectors, + surfaceID: UUID, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationTargetedDeliveryResolution { .tabManagerUnavailable } + + func controlNotificationCreateForTarget( + routing: ControlRoutingSelectors, + workspaceID: UUID, + surfaceID: UUID, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationTargetedDeliveryResolution { .tabManagerUnavailable } + + func controlNotificationList() -> [ControlNotificationSnapshot] { [] } + func controlNotificationDismissAllRead() -> Int { 0 } + func controlNotificationDismiss(id: UUID) -> ControlNotificationDismissResolution { .notFound } + func controlNotificationMarkRead(id: UUID) -> ControlNotificationMarkReadResolution { .notFound } + func controlNotificationMarkRead( + workspaceID: UUID, + surfaceID: UUID?, + hasSurfaceSelector: Bool + ) -> Int { 0 } + func controlNotificationMarkReadAll() -> Int { 0 } + func controlNotificationOpen(id: UUID) -> ControlNotificationOpenResolution { .notificationNotFound } + func controlNotificationJumpToUnread() -> ControlNotificationSnapshot? { nil } + func controlNotificationClear() {} + + var notificationStrings: ControlNotificationStrings { + ControlNotificationStrings( + dismissSelectorRequired: "", + idRequired: "", + notFound: "", + markReadSelectorRequired: "", + surfaceIDInvalid: "", + surfaceIDRequiresWorkspace: "", + targetNotFound: "" + ) + } +} diff --git a/Sources/TerminalController+ControlAppFocusContext.swift b/Sources/TerminalController+ControlAppFocusContext.swift new file mode 100644 index 000000000000..79b4fa961027 --- /dev/null +++ b/Sources/TerminalController+ControlAppFocusContext.swift @@ -0,0 +1,20 @@ +import AppKit +import CmuxControlSocket +import Foundation + +/// The app-focus-domain witnesses are the byte-faithful bodies of the former +/// `v2AppFocusOverride` / `v2AppSimulateActive` dispatchers, minus the per-read +/// `v2MainSync` hop: the coordinator already runs on the main actor inside the +/// socket-command policy scope, so each hop would re-apply the identical +/// thread-local focus-allowance stack — a no-op. +extension TerminalController: ControlAppFocusContext { + func controlSetAppFocusOverride(_ focused: Bool?) { + AppFocusState.overrideIsFocused = focused + } + + func controlSimulateAppActive() { + AppDelegate.shared?.applicationDidBecomeActive( + Notification(name: NSApplication.didBecomeActiveNotification) + ) + } +} diff --git a/Sources/TerminalController+ControlFeedContext.swift b/Sources/TerminalController+ControlFeedContext.swift new file mode 100644 index 000000000000..b0bd8d84751c --- /dev/null +++ b/Sources/TerminalController+ControlFeedContext.swift @@ -0,0 +1,27 @@ +import CmuxControlSocket +import Foundation + +/// The feed-domain (workstream) witnesses are the byte-faithful bodies of the +/// former `v2FeedJump` / `v2FeedList` dispatchers. Both ran on the main actor +/// already (they were not `nonisolated`), so there is no per-read `v2MainSync` +/// hop to shed; the work is the same `FeedCoordinator.shared` reads the legacy +/// bodies performed, with the per-item encoding (`FeedSocketEncoding.itemDict`) +/// bridged to `JSONValue` so the wire bytes match exactly. +/// +/// Only the MAIN-ACTOR feed methods move here. The worker-lane feed methods +/// (`feed.push`, `feed.permission.reply`, `feed.question.reply`, +/// `feed.exit_plan.reply`) stay on the app-side socket-worker path. +extension TerminalController: ControlFeedContext { + func controlFeedResolvePossibleSurface(workstreamID: String) -> Bool { + FeedCoordinator.shared.resolvePossibleSurface(for: workstreamID) + } + + func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] { + FeedCoordinator.shared.snapshot(pendingOnly: pendingOnly).map { item in + // `FeedSocketEncoding.itemDict` only ever produces valid JSON + // (strings, bools, arrays, nested dicts), so the bridge never fails; + // the empty-object fallback exists solely to keep the map total. + JSONValue(foundationObject: FeedSocketEncoding.itemDict(item)) ?? .object([:]) + } + } +} diff --git a/Sources/TerminalController+ControlNotificationContext.swift b/Sources/TerminalController+ControlNotificationContext.swift new file mode 100644 index 000000000000..78a1b621e64b --- /dev/null +++ b/Sources/TerminalController+ControlNotificationContext.swift @@ -0,0 +1,277 @@ +import CmuxControlSocket +import Foundation + +/// The notification-domain witnesses are the byte-faithful bodies of the former +/// `TerminalController.v2Notification*` dispatchers, minus the per-read +/// `v2MainSync` hop: the coordinator already runs on the main actor inside the +/// socket-command policy scope, so each hop would re-apply the identical +/// thread-local focus-allowance stack — a no-op. +/// +/// `notification.create_for_caller` is intentionally NOT moved here: it has its +/// own self-contained resolver (`TerminalNotificationCallerResolver.swift`) and +/// stays on the legacy app-side dispatcher. +extension TerminalController: ControlNotificationContext { + func controlNotificationCreate( + routing: ControlRoutingSelectors, + explicitSurfaceID: UUID?, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationCreateResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + if let explicitSurfaceID, ws.panels[explicitSurfaceID] == nil { + return .surfaceNotFound(explicitSurfaceID) + } + let surfaceId = explicitSurfaceID ?? ws.focusedPanelId + deliverNotificationSynchronously( + tabId: ws.id, + surfaceId: surfaceId, + title: title, + subtitle: subtitle, + body: body + ) + return .delivered(workspaceID: ws.id, surfaceID: surfaceId) + } + + func controlNotificationCreateForSurface( + routing: ControlRoutingSelectors, + surfaceID: UUID, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationTargetedDeliveryResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound(workspaceID: nil) + } + guard ws.panels[surfaceID] != nil else { + return .surfaceNotFound(surfaceID) + } + deliverNotificationSynchronously( + tabId: ws.id, + surfaceId: surfaceID, + title: title, + subtitle: subtitle, + body: body + ) + return .delivered( + workspaceID: ws.id, + surfaceID: surfaceID, + windowID: AppDelegate.shared?.windowId(for: tabManager) + ) + } + + func controlNotificationCreateForTarget( + routing: ControlRoutingSelectors, + workspaceID: UUID, + surfaceID: UUID, + title: String, + subtitle: String, + body: String + ) -> ControlNotificationTargetedDeliveryResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = tabManager.tabs.first(where: { $0.id == workspaceID }) else { + return .workspaceNotFound(workspaceID: workspaceID) + } + guard ws.panels[surfaceID] != nil else { + return .surfaceNotFound(surfaceID) + } + deliverNotificationSynchronously( + tabId: ws.id, + surfaceId: surfaceID, + title: title, + subtitle: subtitle, + body: body + ) + return .delivered( + workspaceID: ws.id, + surfaceID: surfaceID, + windowID: AppDelegate.shared?.windowId(for: tabManager) + ) + } + + func controlNotificationList() -> [ControlNotificationSnapshot] { + TerminalNotificationStore.shared.notifications.map { Self.controlSnapshot($0) } + } + + func controlNotificationDismissAllRead() -> Int { + let readIds = TerminalNotificationStore.shared.notifications + .filter(\.isRead) + .map(\.id) + for id in readIds { + TerminalNotificationStore.shared.remove(id: id) + } + return readIds.count + } + + func controlNotificationDismiss(id: UUID) -> ControlNotificationDismissResolution { + let store = TerminalNotificationStore.shared + guard let notification = store.notifications.first(where: { $0.id == id }) else { + return .notFound + } + let snapshot = Self.controlSnapshot(notification) + store.remove(id: id) + return .dismissed(snapshot) + } + + func controlNotificationMarkRead(id: UUID) -> ControlNotificationMarkReadResolution { + let store = TerminalNotificationStore.shared + let before = store.notifications + guard before.contains(where: { $0.id == id }) else { + return .notFound + } + store.markRead(id: id) + let afterById = Dictionary(uniqueKeysWithValues: store.notifications.map { ($0.id, $0.isRead) }) + let count = before.filter { !$0.isRead && afterById[$0.id] == true }.count + return .marked(count: count) + } + + func controlNotificationMarkRead( + workspaceID: UUID, + surfaceID: UUID?, + hasSurfaceSelector: Bool + ) -> Int { + let store = TerminalNotificationStore.shared + let before = store.notifications + if hasSurfaceSelector { + store.markRead(forTabId: workspaceID, surfaceId: surfaceID) + } else { + store.markRead(forTabId: workspaceID) + } + return Self.markedCount(before: before, store: store) + } + + func controlNotificationMarkReadAll() -> Int { + let store = TerminalNotificationStore.shared + let before = store.notifications + store.markAllRead() + return Self.markedCount(before: before, store: store) + } + + func controlNotificationOpen(id: UUID) -> ControlNotificationOpenResolution { + let store = TerminalNotificationStore.shared + guard let notification = store.notifications.first(where: { $0.id == id }) else { + return .notificationNotFound + } + let opened = AppDelegate.shared?.openTerminalNotification(notification) ?? false + let current = store.notifications.first(where: { $0.id == notification.id }) ?? notification + let snapshot = Self.controlSnapshot(current) + return opened ? .opened(snapshot) : .targetNotFound(snapshot) + } + + func controlNotificationJumpToUnread() -> ControlNotificationSnapshot? { + guard let opened = AppDelegate.shared?.jumpToLatestUnread() else { return nil } + let store = TerminalNotificationStore.shared + let current = store.notifications.first(where: { $0.id == opened.id }) ?? opened + return Self.controlSnapshot(current) + } + + func controlNotificationClear() { + TerminalMutationBus.shared.enqueueClearAllNotifications() + } + + var notificationStrings: ControlNotificationStrings { + ControlNotificationStrings( + dismissSelectorRequired: String( + localized: "socket.notification.dismissSelectorRequired", + defaultValue: "Select exactly one of id or all_read" + ), + idRequired: String( + localized: "socket.notification.idRequired", + defaultValue: "Missing or invalid notification id" + ), + notFound: String( + localized: "socket.notification.notFound", + defaultValue: "Notification not found" + ), + markReadSelectorRequired: String( + localized: "socket.notification.markReadSelectorRequired", + defaultValue: "Select exactly one of id, tab_id, or all" + ), + surfaceIDInvalid: String( + localized: "socket.notification.surfaceIdInvalid", + defaultValue: "Missing or invalid surface_id" + ), + surfaceIDRequiresWorkspace: String( + localized: "socket.notification.surfaceIdRequiresWorkspace", + defaultValue: "surface_id requires tab_id or workspace_id" + ), + targetNotFound: String( + localized: "socket.notification.targetNotFound", + defaultValue: "Notification target not found" + ) + ) + } + + // MARK: - Resolution helpers (private, file-scoped) + + /// The routing-driven twin of the legacy `v2ResolveWorkspace(params:tabManager:)`: + /// workspace id, then the surface set (`surface_id`/`terminal_id`/`tab_id`, + /// already collapsed into `routing.surfaceID`), then pane, then the + /// TabManager's selected tab. + private func resolveWorkspace( + routing: ControlRoutingSelectors, + tabManager: TabManager + ) -> Workspace? { + if let wsId = routing.workspaceID { + return tabManager.tabs.first(where: { $0.id == wsId }) + } + if let surfaceId = routing.surfaceID { + return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) + } + if let paneId = routing.paneID, let located = v2LocatePane(paneId) { + guard located.tabManager === tabManager else { return nil } + return located.workspace + } + guard let wsId = tabManager.selectedTabId else { return nil } + return tabManager.tabs.first(where: { $0.id == wsId }) + } + + /// The marked-read delta the legacy bodies computed: notifications that were + /// unread before and are read after. + private static func markedCount( + before: [TerminalNotification], + store: TerminalNotificationStore + ) -> Int { + let afterById = Dictionary(uniqueKeysWithValues: store.notifications.map { ($0.id, $0.isRead) }) + return before.filter { !$0.isRead && afterById[$0.id] == true }.count + } + + /// Converts a `TerminalNotification` to the Sendable snapshot, pre-rendering + /// the ISO-8601 `created_at` and resolving the workspace tab title exactly as + /// the legacy `notificationPayload` builder did. The date rendering mirrors + /// the (file-private) `TerminalController.notificationCreatedAtString`. + private static func controlSnapshot( + _ notification: TerminalNotification + ) -> ControlNotificationSnapshot { + ControlNotificationSnapshot( + id: notification.id, + workspaceID: notification.tabId, + surfaceID: notification.surfaceId, + title: notification.title, + subtitle: notification.subtitle, + body: notification.body, + createdAtISO8601: notificationCreatedAtISO8601(notification.createdAt), + isRead: notification.isRead, + tabTitle: AppDelegate.shared?.tabTitle(for: notification.tabId) + ) + } + + /// Byte-identical reproduction of the file-private + /// `TerminalController.notificationCreatedAtString`. + private static func notificationCreatedAtISO8601(_ date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 8da239ac122a..7f42fea0d0ac 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2035,11 +2035,7 @@ class TerminalController { case "feedback.open": return v2Result(id: id, self.v2FeedbackOpen(params: params)) - // Feed (workstream) - case "feed.jump": - return v2Result(id: id, self.v2FeedJump(params: params)) - case "feed.list": - return v2Result(id: id, self.v2FeedList(params: params)) + // Feed (workstream): feed.jump/feed.list handled by ControlCommandCoordinator. // Surfaces / input @@ -2116,33 +2112,12 @@ class TerminalController { case "pane.last": return v2Result(id: id, self.v2PaneLast(params: params)) - // Notifications - case "notification.create": - return v2Result(id: id, self.v2NotificationCreate(params: params)) + // Notifications: all but notification.create_for_caller handled by + // ControlCommandCoordinator (create_for_caller keeps its app-side resolver). case "notification.create_for_caller": return v2Result(id: id, self.v2NotificationCreateForCaller(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()) - case "notification.dismiss": - return v2Result(id: id, self.v2NotificationDismiss(params: params)) - case "notification.mark_read": - return v2Result(id: id, self.v2NotificationMarkRead(params: params)) - case "notification.open": - return v2Result(id: id, self.v2NotificationOpen(params: params)) - case "notification.jump_to_unread": - return v2Result(id: id, self.v2NotificationJumpToUnread()) - - // 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()) + + // App focus (app.focus_override.set/app.simulate_active) handled by ControlCommandCoordinator. // Browser case "browser.open_split": @@ -9925,345 +9900,6 @@ class TerminalController { 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 explicitSurfaceId = v2UUID(params, "surface_id") - 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 - } - if let explicitSurfaceId, ws.panels[explicitSurfaceId] == nil { - result = .err( - code: "not_found", - message: "Surface not found", - data: ["surface_id": explicitSurfaceId.uuidString] - ) - return - } - let surfaceId = explicitSurfaceId ?? ws.focusedPanelId - deliverNotificationSynchronously( - 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 - } - deliverNotificationSynchronously( - 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 - } - deliverNotificationSynchronously( - 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]] = [] - v2MainSync { - items = TerminalNotificationStore.shared.notifications.map { n in - return notificationPayload(n, opened: nil, includeReadState: true) - } - } - return ["notifications": items] - } - - private func v2NotificationDismiss(params: [String: Any]) -> V2CallResult { - let id = v2UUID(params, "id") - let allRead = v2Bool(params, "all_read") ?? false - let selectorCount = (id == nil ? 0 : 1) + (allRead ? 1 : 0) - - guard selectorCount == 1 else { - return .err( - code: "invalid_params", - message: String(localized: "socket.notification.dismissSelectorRequired", defaultValue: "Select exactly one of id or all_read"), - data: nil - ) - } - - if allRead { - var dismissedCount = 0 - v2MainSync { - let readIds = TerminalNotificationStore.shared.notifications - .filter(\.isRead) - .map(\.id) - for id in readIds { - TerminalNotificationStore.shared.remove(id: id) - } - dismissedCount = readIds.count - } - return .ok(["dismissed": dismissedCount, "all_read": true]) - } - - guard let id else { - return .err( - code: "invalid_params", - message: String(localized: "socket.notification.idRequired", defaultValue: "Missing or invalid notification id"), - data: nil - ) - } - - var dismissed = false - var payload: [String: Any] = [:] - v2MainSync { - let notification = TerminalNotificationStore.shared.notifications.first(where: { $0.id == id }) - if let notification { - payload = notificationPayload(notification, opened: nil, includeReadState: true) - TerminalNotificationStore.shared.remove(id: id) - dismissed = true - } - } - guard dismissed else { - return .err( - code: "not_found", - message: String(localized: "socket.notification.notFound", defaultValue: "Notification not found"), - data: ["id": id.uuidString] - ) - } - payload["dismissed"] = 1 - return .ok(payload) - } - - private func v2NotificationMarkRead(params: [String: Any]) -> V2CallResult { - let id = v2UUID(params, "id") - let tabId = v2UUID(params, "tab_id") ?? v2UUID(params, "workspace_id") - let hasSurfaceSelector = v2HasNonNullParam(params, "surface_id") - let surfaceId = v2UUID(params, "surface_id") - let all = v2Bool(params, "all") ?? false - let selectorCount = (id == nil ? 0 : 1) + (tabId == nil ? 0 : 1) + (all ? 1 : 0) - - guard selectorCount == 1 else { - return .err( - code: "invalid_params", - message: String(localized: "socket.notification.markReadSelectorRequired", defaultValue: "Select exactly one of id, tab_id, or all"), - data: nil - ) - } - if hasSurfaceSelector, surfaceId == nil { - return .err( - code: "invalid_params", - message: String(localized: "socket.notification.surfaceIdInvalid", defaultValue: "Missing or invalid surface_id"), - data: nil - ) - } - if hasSurfaceSelector, tabId == nil { - return .err( - code: "invalid_params", - message: String(localized: "socket.notification.surfaceIdRequiresWorkspace", defaultValue: "surface_id requires tab_id or workspace_id"), - data: nil - ) - } - - var markedCount = 0 - var selectedNotificationExists = true - v2MainSync { - let store = TerminalNotificationStore.shared - let before = store.notifications - if let id { - guard before.contains(where: { $0.id == id }) else { - selectedNotificationExists = false - return - } - store.markRead(id: id) - } else if let tabId { - if hasSurfaceSelector { - store.markRead(forTabId: tabId, surfaceId: surfaceId) - } else { - store.markRead(forTabId: tabId) - } - } else if all { - store.markAllRead() - } - let afterById = Dictionary(uniqueKeysWithValues: store.notifications.map { ($0.id, $0.isRead) }) - markedCount = before.filter { !$0.isRead && afterById[$0.id] == true }.count - } - - if !selectedNotificationExists, let id { - return .err( - code: "not_found", - message: String(localized: "socket.notification.notFound", defaultValue: "Notification not found"), - data: ["id": id.uuidString] - ) - } - - var result: [String: Any] = ["marked_read": markedCount] - if let id { result["id"] = id.uuidString } - if let tabId { - result["workspace_id"] = tabId.uuidString - result["workspace_ref"] = v2Ref(kind: .workspace, uuid: tabId) - } - if hasSurfaceSelector { - result["surface_id"] = v2OrNull(surfaceId?.uuidString) - result["surface_ref"] = v2Ref(kind: .surface, uuid: surfaceId) - } - if all { result["all"] = true } - return .ok(result) - } - - private func v2NotificationOpen(params: [String: Any]) -> V2CallResult { - guard let id = v2UUID(params, "id") else { - return .err( - code: "invalid_params", - message: String(localized: "socket.notification.idRequired", defaultValue: "Missing or invalid notification id"), - data: nil - ) - } - - var notification: TerminalNotification? - var opened = false - var payload: [String: Any] = [:] - v2MainSync { - let store = TerminalNotificationStore.shared - notification = store.notifications.first(where: { $0.id == id }) - if let notification { - opened = AppDelegate.shared?.openTerminalNotification(notification) ?? false - let current = store.notifications.first(where: { $0.id == notification.id }) ?? notification - payload = notificationPayload(current, opened: opened, includeReadState: true) - } - } - - guard notification != nil else { - return .err( - code: "not_found", - message: String(localized: "socket.notification.notFound", defaultValue: "Notification not found"), - data: ["id": id.uuidString] - ) - } - guard opened else { - return .err( - code: "not_found", - message: String(localized: "socket.notification.targetNotFound", defaultValue: "Notification target not found"), - data: payload - ) - } - return .ok(payload) - } - - private func v2NotificationJumpToUnread() -> V2CallResult { - var openedNotification: TerminalNotification? - var payload: [String: Any] = [:] - v2MainSync { - openedNotification = AppDelegate.shared?.jumpToLatestUnread() - if let openedNotification { - let store = TerminalNotificationStore.shared - let current = store.notifications.first(where: { $0.id == openedNotification.id }) ?? openedNotification - payload = notificationPayload(current, opened: true, includeReadState: true) - } - } - guard openedNotification != nil else { - return .ok(["opened": false]) - } - return .ok(payload) - } - - private func notificationPayload( - _ notification: TerminalNotification, - opened: Bool?, - includeReadState: Bool - ) -> [String: Any] { - var payload: [String: Any] = [ - "id": notification.id.uuidString, - "workspace_id": notification.tabId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: notification.tabId), - "surface_id": v2OrNull(notification.surfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: notification.surfaceId), - "title": notification.title, - "subtitle": notification.subtitle, - "body": notification.body, - "created_at": Self.notificationCreatedAtString(notification.createdAt), - "tab_title": v2OrNull(AppDelegate.shared?.tabTitle(for: notification.tabId)), - ] - if includeReadState { - payload["is_read"] = notification.isRead - } - if let opened { - payload["opened"] = opened - } - return payload - } - - private func v2NotificationClear() -> V2CallResult { - TerminalMutationBus.shared.enqueueClearAllNotifications() - return .ok([:]) - } - private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { let workspaceId = v2UUID(params, "workspace_id") let windowId = v2UUID(params, "window_id") @@ -10565,72 +10201,6 @@ class TerminalController { return .ok(["delivered": true]) } - private func v2FeedJump(params: [String: Any]) -> V2CallResult { - guard let workstreamId = params["workstream_id"] as? String else { - return .err( - code: "invalid_params", - message: "feed.jump requires workstream_id", - data: nil - ) - } - // MVP: resolve to a cmux surface via `SessionIndexStore` lands in - // the UI PR; for now we return whether the id is known so callers - // can show a toast. - let matched = FeedCoordinator.shared.resolvePossibleSurface(for: workstreamId) - return .ok([ - "workstream_id": workstreamId, - "matched": matched - ]) - } - - private func v2FeedList(params: [String: Any]) -> V2CallResult { - let pendingOnly = (params["pending_only"] as? Bool) ?? false - let items = FeedCoordinator.shared.snapshot(pendingOnly: pendingOnly) - return .ok([ - "items": items.map { FeedSocketEncoding.itemDict($0) } - ]) - } - - // 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( diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d54214ad31e4..a70a1de19445 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -607,6 +607,9 @@ C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A502000000000000000001 /* TaskManagerWindowController.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; + C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */; }; + C0DE00000000000000000C44 /* TerminalController+ControlFeedContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */; }; + C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; C0DE00000000000000000C32 /* TerminalControllerControlCommandContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */; }; @@ -1343,6 +1346,9 @@ C7A502000000000000000001 /* TaskManagerWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManagerWindowController.swift; sourceTree = ""; }; 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; + C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlAppFocusContext.swift"; sourceTree = ""; }; + C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlFeedContext.swift"; sourceTree = ""; }; + C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlNotificationContext.swift"; sourceTree = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerControlCommandContext.swift; sourceTree = ""; }; @@ -1850,6 +1856,9 @@ D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */, C7A50A000000000000000001 /* TerminalControllerPaneResizeSupport.swift */, C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */, + C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */, + C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */, + C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */, C7A50B000000000000000001 /* TerminalControllerV2ParamParsingSupport.swift */, C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, @@ -3017,6 +3026,9 @@ C7A504000000000000000002 /* TaskManagerTypes.swift in Sources */, C7A506000000000000000002 /* TaskManagerView.swift in Sources */, C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, + C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */, + C0DE00000000000000000C44 /* TerminalController+ControlFeedContext.swift in Sources */, + C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */, D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, C0DE00000000000000000C32 /* TerminalControllerControlCommandContext.swift in Sources */, From 9bdae001d7165ecbaab0687a91382c448747c5bb Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 14:26:11 -0700 Subject: [PATCH 04/21] stage 3c: extract Mobile Host + Workspace Groups + Pane domains Move workspace.group.* (17 methods), pane.* (9 methods), and mobile.host.status/ mobile.workspace.list/mobile.terminal.* (+terminal.* aliases) into the coordinator behind ControlWorkspaceGroupContext/ControlPaneContext/ControlMobileHostContext, composed into the umbrella; core handle(_:) chains the new handlers. Workspace Groups + Pane are full lifts (bodies deleted, payloads rebuilt as JSONValue, localized group strings routed app-side via ControlWorkspaceGroupStrings). Mobile Host is a faithful pass-through: its 8 bodies are SHARED with the mobile data-plane (mobileHostHandleRPC) so they stay in TerminalController (relaxed private->internal); the coordinator decouples via the seam and the conformance bridges V2CallResult. Pane folds the resize support helpers (kept app-side: Bonsplit-coupled); v2SurfaceMove relaxed private->internal for pane.join forwarding. Live socket sweep on ctl3c1 confirms faithful payloads + errors (group create/list, pane list/create split, mobile host status). TerminalController.swift 21522 -> 20296. 128 package tests green. Two new Pane files >500 lines get budget entries. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 4 +- .../Coordinator/ControlCommandContext.swift | 5 +- ...ControlCommandCoordinator+MobileHost.swift | 49 + .../ControlCommandCoordinator+Pane.swift | 533 +++++++ ...rolCommandCoordinator+WorkspaceGroup.swift | 468 ++++++ .../ControlCommandCoordinator.swift | 3 + .../ControlMobileHostContext.swift | 103 ++ .../ControlPaneBreakResolution.swift | 38 + .../Coordinator/ControlPaneContext.swift | 137 ++ .../Coordinator/ControlPaneCreateInputs.swift | 82 ++ .../ControlPaneCreateResolution.swift | 62 + .../ControlPaneFocusResolution.swift | 22 + .../Coordinator/ControlPaneGridSize.swift | 30 + .../ControlPaneJoinResolution.swift | 24 + .../ControlPaneLastResolution.swift | 21 + .../Coordinator/ControlPaneListSnapshot.swift | 43 + .../Coordinator/ControlPanePixelFrame.swift | 30 + .../Coordinator/ControlPaneResizeInputs.swift | 45 + .../ControlPaneResizeResolution.swift | 70 + .../Coordinator/ControlPaneSummary.swift | 48 + .../ControlPaneSurfaceSummary.swift | 39 + .../ControlPaneSurfacesSnapshot.swift | 37 + .../ControlPaneSwapResolution.swift | 46 + .../ControlWorkspaceGroupAddResolution.swift | 24 + .../ControlWorkspaceGroupContext.swift | 246 ++++ ...ontrolWorkspaceGroupCreateResolution.swift | 33 + ...ControlWorkspaceGroupFocusResolution.swift | 19 + .../ControlWorkspaceGroupListResolution.swift | 16 + ...WorkspaceGroupNewWorkspaceResolution.swift | 24 + .../ControlWorkspaceGroupSnapshot.swift | 59 + .../ControlWorkspaceGroupStrings.swift | 32 + .../ControlCommandContextTestStubs.swift | 110 ++ ...lController+ControlMobileHostContext.swift | 83 ++ ...erminalController+ControlPaneContext.swift | 611 ++++++++ ...troller+ControlWorkspaceGroupContext.swift | 356 +++++ Sources/TerminalController.swift | 1256 +---------------- cmux.xcodeproj/project.pbxproj | 12 + 37 files changed, 3577 insertions(+), 1243 deletions(-) create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+MobileHost.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+WorkspaceGroup.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMobileHostContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneBreakResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneFocusResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneGridSize.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneJoinResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneLastResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneListSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPanePixelFrame.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSummary.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfaceSummary.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfacesSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSwapResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupAddResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupCreateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupFocusResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupListResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupNewWorkspaceResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupStrings.swift create mode 100644 Sources/TerminalController+ControlMobileHostContext.swift create mode 100644 Sources/TerminalController+ControlPaneContext.swift create mode 100644 Sources/TerminalController+ControlWorkspaceGroupContext.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index b5a1b005ec31..c1831e5f6020 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,7 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 32655 CLI/cmux.swift -21522 Sources/TerminalController.swift +20296 Sources/TerminalController.swift 19820 Sources/Workspace.swift 19209 Sources/ContentView.swift 18011 Sources/AppDelegate.swift @@ -186,3 +186,5 @@ 504 cmuxTests/TerminalNotificationSocketActionTests.swift 502 Sources/CmuxEventPublishing.swift 502 Sources/Settings/ConfigSource.swift +611 Sources/TerminalController+ControlPaneContext.swift +533 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift index 54c9c509bc16..c3802ac4a0b4 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift @@ -17,5 +17,8 @@ public protocol ControlCommandContext: ControlWindowContext, ControlAppFocusContext, ControlFeedContext, - ControlNotificationContext + ControlNotificationContext, + ControlWorkspaceGroupContext, + ControlPaneContext, + ControlMobileHostContext {} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+MobileHost.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+MobileHost.swift new file mode 100644 index 000000000000..ef300b69b216 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+MobileHost.swift @@ -0,0 +1,49 @@ +internal import Foundation + +/// The mobile-host domain (`mobile.*` / `terminal.*`), lifted byte-faithfully +/// from the former `TerminalController.v2Mobile*` bodies that `processV2Command` +/// dispatched. +/// +/// These bodies build deeply nested, app-state-derived Foundation payloads and +/// resolve their target through the legacy `v2ResolveTabManager` precedence, and +/// none of them mint `kind:N` refs. So each coordinator method is a thin +/// pass-through to its ``ControlMobileHostContext`` seam method, which runs the +/// exact legacy body app-side and bridges the resulting Foundation payload to a +/// ``JSONValue`` — the wire bytes are identical. The localized terminal-input +/// error strings resolve against the app bundle in the conformance, so moving +/// the dispatch here does not change them. +/// +/// The aliases mirror `processV2Command` exactly: `mobile.workspace.list` (the +/// bare `workspace.list` stays on the legacy `v2WorkspaceList`), and the +/// `mobile.terminal.*` verbs each with their bare `terminal.*` alias. The +/// worker-lane `mobile.attach_ticket.create` and the mobile-data-plane-only +/// verbs are deliberately not handled here. +extension ControlCommandCoordinator { + /// Dispatches the mobile-host methods this coordinator owns; returns `nil` + /// for anything else so the core `handle(_:)` can fall through. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a mobile-host method. + func handleMobileHost(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "mobile.host.status": + return context?.controlMobileHostStatus(params: request.params) + case "mobile.workspace.list": + return context?.controlMobileWorkspaceList(params: request.params) + case "mobile.terminal.create", "terminal.create": + return context?.controlMobileTerminalCreate(params: request.params) + case "mobile.terminal.input", "terminal.input": + return context?.controlMobileTerminalInput(params: request.params) + case "mobile.terminal.replay", "terminal.replay": + return context?.controlMobileTerminalReplay(params: request.params) + case "mobile.terminal.viewport", "terminal.viewport": + return context?.controlMobileTerminalViewport(params: request.params) + case "mobile.terminal.scroll", "terminal.scroll": + return context?.controlMobileTerminalScroll(params: request.params) + case "mobile.terminal.mouse", "terminal.mouse": + return context?.controlMobileTerminalMouse(params: request.params) + default: + return nil + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift new file mode 100644 index 000000000000..6ca9a8e059c8 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift @@ -0,0 +1,533 @@ +internal import Foundation + +/// The pane domain (`pane.*`), lifted byte-faithfully from the former +/// `TerminalController.v2Pane*` bodies. Each payload is built directly as a +/// ``JSONValue`` (the typed twin of the legacy `[String: Any]` dictionaries); +/// the resulting Foundation object is identical, so the encoded wire bytes +/// match. The coordinator owns the param parsing and ref minting; the app-coupled +/// work (Bonsplit layout, split creation/resize, surface moves) runs behind the +/// ``ControlPaneContext`` seam. +extension ControlCommandCoordinator { + + /// Runs one decoded request if it belongs to the pane domain, returning the + /// typed result; returns `nil` otherwise so the caller can fall through. The + /// integrator calls this from the core `handle`. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a pane method. + func handlePane(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "pane.list": + return paneList(request.params) + case "pane.focus": + return paneFocus(request.params) + case "pane.surfaces": + return paneSurfaces(request.params) + case "pane.create": + return paneCreate(request.params) + case "pane.resize": + return paneResize(request.params) + case "pane.swap": + return paneSwap(request.params) + case "pane.break": + return paneBreak(request.params) + case "pane.join": + return paneJoin(request.params) + case "pane.last": + return paneLast(request.params) + default: + return nil + } + } + + // MARK: - list + + /// `pane.list` — the resolved workspace's pane layout. + func paneList(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let snapshot = context?.controlPaneList(routing: routing) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + + let panes: [JSONValue] = snapshot.panes.enumerated().map { index, pane in + var dict: [String: JSONValue] = [ + "id": .string(pane.paneID.uuidString), + "ref": ref(.pane, pane.paneID), + "index": .int(Int64(index)), + "focused": .bool(pane.isFocused), + "surface_ids": .array(pane.surfaceIDs.map { .string($0.uuidString) }), + "surface_refs": .array(pane.surfaceIDs.map { ref(.surface, $0) }), + "selected_surface_id": orNull(pane.selectedSurfaceID?.uuidString), + "selected_surface_ref": ref(.surface, pane.selectedSurfaceID), + "surface_count": .int(Int64(pane.surfaceIDs.count)), + ] + if let frame = pane.pixelFrame { + dict["pixel_frame"] = .object([ + "x": .double(frame.x), + "y": .double(frame.y), + "width": .double(frame.width), + "height": .double(frame.height), + ]) + } + if let grid = pane.gridSize { + dict["columns"] = .int(Int64(grid.columns)) + dict["rows"] = .int(Int64(grid.rows)) + dict["cell_width_px"] = .int(Int64(grid.cellWidthPx)) + dict["cell_height_px"] = .int(Int64(grid.cellHeightPx)) + } + return .object(dict) + } + + return .ok(.object([ + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "panes": .array(panes), + "window_id": orNull(snapshot.windowID?.uuidString), + "window_ref": ref(.window, snapshot.windowID), + "container_frame": .object([ + "width": .double(snapshot.containerWidth), + "height": .double(snapshot.containerHeight), + ]), + ])) + } + + // MARK: - focus + + /// `pane.focus` — focus a pane in the resolved workspace. + func paneFocus(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let paneID = uuid(params, "pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil) + } + let resolution = context?.controlPaneFocus(routing: routing, paneID: paneID) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .paneNotFound(let id): + return .err( + code: "not_found", + message: "Pane not found", + data: .object(["pane_id": .string(id.uuidString)]) + ) + case .focused(let windowID, let workspaceID, let focusedPaneID): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(focusedPaneID.uuidString), + "pane_ref": ref(.pane, focusedPaneID), + ])) + } + } + + // MARK: - surfaces + + /// `pane.surfaces` — the surfaces in one pane. + func paneSurfaces(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let snapshot = context?.controlPaneSurfaces( + routing: routing, + paneID: uuid(params, "pane_id") + ) else { + return .err(code: "not_found", message: "Pane or workspace not found", data: nil) + } + + let surfaces: [JSONValue] = snapshot.surfaces.enumerated().map { index, surface in + .object([ + "id": orNull(surface.surfaceID?.uuidString), + "ref": ref(.surface, surface.surfaceID), + "index": .int(Int64(index)), + "title": .string(surface.title), + "type": orNull(surface.typeRawValue), + "selected": .bool(surface.isSelected), + ]) + } + + return .ok(.object([ + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "pane_id": .string(snapshot.paneID.uuidString), + "pane_ref": ref(.pane, snapshot.paneID), + "surfaces": .array(surfaces), + "window_id": orNull(snapshot.windowID?.uuidString), + "window_ref": ref(.window, snapshot.windowID), + ])) + } + + // MARK: - create + + /// `pane.create` — split the source surface into a new pane. + func paneCreate(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let inputs = ControlPaneCreateInputs( + directionRaw: string(params, "direction"), + typeRaw: string(params, "type"), + urlRaw: string(params, "url"), + workingDirectory: optionalTrimmedRawString(params, "working_directory"), + initialCommand: optionalTrimmedRawString(params, "initial_command"), + tmuxStartCommand: optionalTrimmedRawString(params, "tmux_start_command"), + startupEnvironment: trimmedStringMap(params, keys: ["startup_environment", "initial_env"]), + requestedSourceSurfaceID: string(params, "surface_id").flatMap(UUID.init(uuidString:)), + requestedFocus: bool(params, "focus") ?? false, + hasInitialDividerPosition: hasNonNull(params, "initial_divider_position"), + initialDividerPositionRaw: double(params, "initial_divider_position") + ) + + let resolution = context?.controlPaneCreate(routing: routing, inputs: inputs) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidDirection: + return .err( + code: "invalid_params", + message: "Missing or invalid direction (left|right|up|down)", + data: nil + ) + case .invalidDividerPosition: + return .err( + code: "invalid_params", + message: "initial_divider_position must be numeric", + data: nil + ) + case .agentSessionRejected(let typeRawValue): + return .err( + code: "invalid_params", + message: "agent-session is only supported by surface.create", + data: .object(["type": .string(typeRawValue)]) + ) + case .browserDisabledInvalidURL(let rawURL): + return .err(code: "invalid_params", message: "Invalid URL", data: .object(["url": .string(rawURL)])) + case .browserDisabledNoURL: + return .err(code: "browser_disabled", message: "cmux browser is disabled", data: nil) + case .browserDisabledExternalOpenFailed(let url): + return .err( + code: "external_open_failed", + message: "Failed to open URL externally", + data: .object(["url": .string(url)]) + ) + case .browserDisabledOpenedExternally(let windowID, let url): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .null, + "workspace_ref": .null, + "pane_id": .null, + "pane_ref": .null, + "surface_id": .null, + "surface_ref": .null, + "created_split": .bool(false), + "opened_externally": .bool(true), + "browser_disabled": .bool(true), + "placement_strategy": .string("external_browser_disabled"), + "url": .string(url), + ])) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noSourceSurface: + return .err(code: "not_found", message: "No source surface to split", data: nil) + case .createFailed: + return .err(code: "internal_error", message: "Failed to create pane", data: nil) + case .created(let windowID, let workspaceID, let paneID, let surfaceID, let typeRawValue): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": orNull(paneID?.uuidString), + "pane_ref": ref(.pane, paneID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "type": .string(typeRawValue), + ])) + } + } + + // MARK: - resize + + /// `pane.resize` — move a split divider (relative or absolute). + func paneResize(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let absoluteAxis = string(params, "absolute_axis")?.lowercased() + let targetPixels = double(params, "target_pixels") + let directionRaw = (string(params, "direction") ?? "").lowercased() + let amount = int(params, "amount") ?? 1 + let directionValid = ["left", "right", "up", "down"].contains(directionRaw) + let hasAbsoluteIntent = params.keys.contains("absolute_axis") || params.keys.contains("target_pixels") + if hasAbsoluteIntent { + guard let absoluteAxis, absoluteAxis == "horizontal" || absoluteAxis == "vertical" else { + return .err(code: "invalid_params", message: "absolute_axis must be 'horizontal' or 'vertical'", data: nil) + } + guard let targetPixels, targetPixels > 0 else { + return .err(code: "invalid_params", message: "target_pixels must be > 0", data: nil) + } + } else { + guard directionValid, 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) + } + } + + let inputs = ControlPaneResizeInputs( + paneID: uuid(params, "pane_id"), + absoluteAxis: absoluteAxis, + targetPixels: targetPixels, + direction: directionValid ? directionRaw : nil, + amount: amount + ) + let resolution = context?.controlPaneResize(routing: routing, inputs: inputs) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noFocusedPane: + return .err(code: "not_found", message: "No focused pane", data: nil) + case .paneNotFound(let id): + return .err(code: "not_found", message: "Pane not found", data: .object(["pane_id": .string(id.uuidString)])) + case .paneNotFoundInTree(let id): + return .err(code: "not_found", message: "Pane not found in split tree", data: .object(["pane_id": .string(id.uuidString)])) + case .noAbsoluteSplitAncestor(let paneID, let axis): + return .err( + code: "invalid_state", + message: "No split ancestor for absolute pane resize", + data: .object(["pane_id": .string(paneID.uuidString), "absolute_axis": orNull(axis)]) + ) + case .noOrientationSplitAncestor(let paneID, let orientation, let direction): + return .err( + code: "invalid_state", + message: "No \(orientation) split ancestor for pane", + data: .object(["pane_id": .string(paneID.uuidString), "direction": .string(direction)]) + ) + case .noAdjacentBorder(let paneID, let direction): + return .err( + code: "invalid_state", + message: "Pane has no adjacent border in direction \(direction)", + data: .object(["pane_id": .string(paneID.uuidString), "direction": .string(direction)]) + ) + case .setDividerFailed(let splitID): + return .err( + code: "internal_error", + message: "Failed to set split divider position", + data: .object(["split_id": .string(splitID.uuidString)]) + ) + case .absoluteResized(let windowID, let workspaceID, let paneID, let splitID, let axis, let targetPixels, let old, let new): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(paneID.uuidString), + "pane_ref": ref(.pane, paneID), + "split_id": .string(splitID.uuidString), + "absolute_axis": .string(axis), + "target_pixels": .double(targetPixels), + "old_divider_position": .double(old), + "new_divider_position": .double(new), + ])) + case .relativeResized(let windowID, let workspaceID, let paneID, let splitID, let direction, let amount, let old, let new): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(paneID.uuidString), + "pane_ref": ref(.pane, paneID), + "split_id": .string(splitID.uuidString), + "direction": .string(direction), + "amount": .int(Int64(amount)), + "old_divider_position": .double(old), + "new_divider_position": .double(new), + ])) + } + } + + // MARK: - swap + + /// `pane.swap` — swap the selected surfaces of two panes. + func paneSwap(_ params: [String: JSONValue]) -> ControlCallResult { + guard let sourcePaneID = uuid(params, "pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil) + } + guard let targetPaneID = uuid(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + if sourcePaneID == targetPaneID { + return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil) + } + let resolution = context?.controlPaneSwap( + sourcePaneID: sourcePaneID, + targetPaneID: targetPaneID, + requestedFocus: bool(params, "focus") ?? false + ) + guard let resolution else { + return .err(code: "internal_error", message: "Failed to swap panes", data: nil) + } + switch resolution { + case .sourcePaneNotFound(let id): + return .err(code: "not_found", message: "Source pane not found", data: .object(["pane_id": .string(id.uuidString)])) + case .targetPaneNotFound(let id): + return .err(code: "not_found", message: "Target pane not found in source workspace", data: .object(["target_pane_id": .string(id.uuidString)])) + case .bothPanesNeedSurface: + return .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil) + case .sourcePlaceholderFailed: + return .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil) + case .targetPlaceholderFailed: + return .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil) + case .moveSourceFailed: + return .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil) + case .moveTargetFailed: + return .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil) + case .swapped(let windowID, let workspaceID, let sourcePane, let targetPane, let sourceSurface, let targetSurface): + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(sourcePane.uuidString), + "pane_ref": ref(.pane, sourcePane), + "target_pane_id": .string(targetPane.uuidString), + "target_pane_ref": ref(.pane, targetPane), + "source_surface_id": .string(sourceSurface.uuidString), + "source_surface_ref": ref(.surface, sourceSurface), + "target_surface_id": .string(targetSurface.uuidString), + "target_surface_ref": ref(.surface, targetSurface), + ])) + } + } + + // MARK: - break + + /// `pane.break` — detach a surface into a new workspace. + func paneBreak(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let resolution = context?.controlPaneBreak( + routing: routing, + paneID: uuid(params, "pane_id"), + surfaceID: uuid(params, "surface_id"), + requestedFocus: bool(params, "focus") ?? false + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noSourceSurface: + return .err(code: "not_found", message: "No source surface to break", data: nil) + case .surfaceNotFound(let id): + return .err(code: "not_found", message: "Surface not found", data: .object(["surface_id": .string(id.uuidString)])) + case .detachFailed: + return .err(code: "internal_error", message: "Failed to detach source surface", data: nil) + case .createWorkspaceFailed: + return .err(code: "internal_error", message: "Failed to create workspace for detached surface", data: nil) + case .destinationPaneUnresolved(let workspaceID, let surfaceID): + return .err( + code: "internal_error", + message: "Failed to resolve destination pane for detached surface", + data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "surface_id": .string(surfaceID.uuidString), + ]) + ) + case .broken(let windowID, let workspaceID, let paneID, let surfaceID): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(paneID.uuidString), + "pane_ref": ref(.pane, paneID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + ])) + } + } + + // MARK: - join + + /// `pane.join` — move a surface into a target pane (via surface-move logic). + func paneJoin(_ params: [String: JSONValue]) -> ControlCallResult { + guard let targetPaneID = uuid(params, "target_pane_id") else { + return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil) + } + let hasFocusParam = bool(params, "focus") != nil + let resolution = context?.controlPaneJoin( + targetPaneID: targetPaneID, + surfaceID: uuid(params, "surface_id"), + sourcePaneID: uuid(params, "pane_id"), + hasFocusParam: hasFocusParam, + focus: bool(params, "focus") ?? false + ) + guard let resolution else { + return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil) + } + switch resolution { + case .sourceSurfaceUnresolved(let sourcePaneID): + return .err( + code: "not_found", + message: "Unable to resolve selected surface in source pane", + data: .object(["pane_id": .string(sourcePaneID.uuidString)]) + ) + case .missingSurface: + return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil) + case .moved(let result): + return result + } + } + + // MARK: - last + + /// `pane.last` — focus the alternate pane. + func paneLast(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlPaneRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let resolution = context?.controlPaneLast(routing: routing) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noFocusedPane: + return .err(code: "not_found", message: "No focused pane", data: nil) + case .noAlternatePane: + return .err(code: "not_found", message: "No alternate pane available", data: nil) + case .focused(let windowID, let workspaceID, let paneID, let selectedSurfaceID): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(paneID.uuidString), + "pane_ref": ref(.pane, paneID), + "surface_id": orNull(selectedSurfaceID?.uuidString), + "surface_ref": ref(.surface, selectedSurfaceID), + ])) + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+WorkspaceGroup.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+WorkspaceGroup.swift new file mode 100644 index 000000000000..5b3afaa87480 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+WorkspaceGroup.swift @@ -0,0 +1,468 @@ +internal import Foundation + +/// The workspace-group domain (`workspace.group.*`), lifted byte-faithfully from +/// the former `TerminalController.v2WorkspaceGroup*` bodies. Each payload is +/// built directly as a ``JSONValue`` (the typed twin of the legacy +/// `[String: Any]` dictionaries); the resulting Foundation object is identical, +/// so the encoded wire bytes match. +extension ControlCommandCoordinator { + /// Dispatches the workspace-group methods this coordinator owns; returns + /// `nil` for anything else so the core `handle(_:)` can fall through. Some + /// methods map onto one body with a flag (collapse/expand → `setCollapsed`, + /// pin/unpin → `setPinned`), preserving the legacy dispatch. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a workspace-group method. + func handleWorkspaceGroup(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "workspace.group.list": + return workspaceGroupList(request.params) + case "workspace.group.create": + return workspaceGroupCreate(request.params) + case "workspace.group.ungroup": + return workspaceGroupUngroup(request.params) + case "workspace.group.delete": + return workspaceGroupDelete(request.params) + case "workspace.group.rename": + return workspaceGroupRename(request.params) + case "workspace.group.collapse": + return workspaceGroupSetCollapsed(request.params, isCollapsed: true) + case "workspace.group.expand": + return workspaceGroupSetCollapsed(request.params, isCollapsed: false) + case "workspace.group.pin": + return workspaceGroupSetPinned(request.params, isPinned: true) + case "workspace.group.unpin": + return workspaceGroupSetPinned(request.params, isPinned: false) + case "workspace.group.add": + return workspaceGroupAdd(request.params) + case "workspace.group.remove": + return workspaceGroupRemove(request.params) + case "workspace.group.set_anchor": + return workspaceGroupSetAnchor(request.params) + case "workspace.group.new_workspace": + return workspaceGroupNewWorkspace(request.params) + case "workspace.group.set_color": + return workspaceGroupSetColor(request.params) + case "workspace.group.set_icon": + return workspaceGroupSetIcon(request.params) + case "workspace.group.move": + return workspaceGroupMove(request.params) + case "workspace.group.focus": + return workspaceGroupFocus(request.params) + default: + return nil + } + } + + // MARK: - Payload + + /// Builds one group's payload row (the legacy `v2WorkspaceGroupPayload`), + /// minting the `workspace_group` / `workspace` refs from the snapshot ids. + private func workspaceGroupPayload(_ group: ControlWorkspaceGroupSnapshot) -> JSONValue { + .object([ + "id": .string(group.id.uuidString), + "ref": ref(.workspaceGroup, group.id), + "name": .string(group.name), + "is_collapsed": .bool(group.isCollapsed), + "is_pinned": .bool(group.isPinned), + "anchor_workspace_id": .string(group.anchorWorkspaceID.uuidString), + "anchor_workspace_ref": ref(.workspace, group.anchorWorkspaceID), + "custom_color": orNull(group.customColor), + "icon_symbol": orNull(group.iconSymbol), + "member_workspace_ids": .array(group.memberWorkspaceIDs.map { .string($0.uuidString) }), + "member_workspace_refs": .array(group.memberWorkspaceIDs.map { ref(.workspace, $0) }), + "member_count": .int(Int64(group.memberWorkspaceIDs.count)), + ]) + } + + // MARK: - List + + /// `workspace.group.list` — every workspace group in the resolved window. + func workspaceGroupList(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = context?.controlWorkspaceGroupList(routing: routingSelectors(params)) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .resolved(let windowID, let groups): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "groups": .array(groups.map { workspaceGroupPayload($0) }), + ])) + } + } + + // MARK: - Create + + /// `workspace.group.create` — create a group from explicit/derived children. + func workspaceGroupCreate(_ params: [String: JSONValue]) -> ControlCallResult { + let name = rawString(params, "name") ?? "" + let cwd = rawString(params, "cwd") + + // child_workspace_ids accepts raw UUID strings AND v2 handle refs + // (workspace:1, ws:1, etc.). A `[String]` array is explicit; any other + // present-non-null shape is rejected; absent/null falls through to the + // app-side fallback selection. + let rawChildren: [String] + let childrenExplicit: Bool + if let provided = stringArrayExact(params["child_workspace_ids"]) { + rawChildren = provided + childrenExplicit = true + } else if let value = params["child_workspace_ids"], !isNull(value) { + return .err( + code: "invalid_params", + message: "child_workspace_ids must be an array of workspace handles", + data: .object([ + "child_workspace_ids": .string(String(describing: value.foundationObject)), + ]) + ) + } else { + // Absent/null: let the app derive children from the active sidebar + // selection / caller workspace / focused workspace. + rawChildren = [] + childrenExplicit = false + } + + var unresolved: [String] = [] + let parsedChildIDs: [UUID] = rawChildren.compactMap { raw -> UUID? in + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if let uuid = uuidAny(.string(trimmed)) { + return uuid + } + unresolved.append(trimmed) + return nil + } + if !unresolved.isEmpty { + return .err( + code: "invalid_params", + message: "Unresolved child workspace handles: \(unresolved.joined(separator: ", "))", + data: .object(["unresolved": .array(unresolved.map { .string($0) })]) + ) + } + + let resolution = context?.controlCreateWorkspaceGroup( + routing: routingSelectors(params), + name: name, + cwd: cwd, + childWorkspaceIDs: parsedChildIDs, + childrenExplicit: childrenExplicit + ) ?? .tabManagerUnavailable + + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .childWorkspaceNotFound(let missing): + return .err( + code: "not_found", + message: "Child workspace not found in target window: \(missing.joined(separator: ", "))", + data: .object(["unknown_workspace_ids": .array(missing.map { .string($0) })]) + ) + case .allChildrenAreAnchors(let ineligible): + return .err( + code: "invalid_state", + message: workspaceGroupStrings().allChildrenAreAnchors, + data: .object(["ineligible_workspace_ids": .array(ineligible.map { .string($0) })]) + ) + case .notCreated: + return .err(code: "not_created", message: "Group was not created", data: nil) + case .created(let group): + return .ok(.object(["group": workspaceGroupPayload(group)])) + } + } + + // MARK: - Ungroup / Delete / Rename + + /// `workspace.group.ungroup` — dissolve a group, keeping its workspaces. + func workspaceGroupUngroup(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + guard let found = context?.controlUngroupWorkspaceGroup(routing: routingSelectors(params), groupID: gid) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard found else { + return .err(code: "not_found", message: "Group not found", data: .object([ + "group_id": .string(gid.uuidString), + ])) + } + return .ok(.object(["group_id": .string(gid.uuidString)])) + } + + /// `workspace.group.delete` — delete a group and close its workspaces. + func workspaceGroupDelete(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + guard let closedCount = context?.controlDeleteWorkspaceGroup(routing: routingSelectors(params), groupID: gid) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard closedCount >= 0 else { + return .err(code: "not_found", message: "Group not found", data: .object([ + "group_id": .string(gid.uuidString), + ])) + } + return .ok(.object([ + "group_id": .string(gid.uuidString), + "closed_workspace_count": .int(Int64(closedCount)), + ])) + } + + /// `workspace.group.rename` — rename a group. + func workspaceGroupRename(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id"), + let name = string(params, "name") else { + return .err(code: "invalid_params", message: "Missing group_id or name", data: nil) + } + guard let ok = context?.controlRenameWorkspaceGroup(routing: routingSelectors(params), groupID: gid, name: name) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return ok + ? .ok(.object(["group_id": .string(gid.uuidString), "name": .string(name)])) + : .err(code: "not_found", message: "Group not found", data: .object(["group_id": .string(gid.uuidString)])) + } + + // MARK: - Collapse / Pin + + /// `workspace.group.collapse` / `.expand` — set the group's collapsed state. + func workspaceGroupSetCollapsed(_ params: [String: JSONValue], isCollapsed: Bool) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + guard let ok = context?.controlSetWorkspaceGroupCollapsed( + routing: routingSelectors(params), groupID: gid, isCollapsed: isCollapsed + ) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return ok + ? .ok(.object(["group_id": .string(gid.uuidString), "is_collapsed": .bool(isCollapsed)])) + : .err(code: "not_found", message: "Group not found", data: .object(["group_id": .string(gid.uuidString)])) + } + + /// `workspace.group.pin` / `.unpin` — set the group's pinned state. + func workspaceGroupSetPinned(_ params: [String: JSONValue], isPinned: Bool) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + guard let ok = context?.controlSetWorkspaceGroupPinned( + routing: routingSelectors(params), groupID: gid, isPinned: isPinned + ) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return ok + ? .ok(.object(["group_id": .string(gid.uuidString), "is_pinned": .bool(isPinned)])) + : .err(code: "not_found", message: "Group not found", data: .object(["group_id": .string(gid.uuidString)])) + } + + // MARK: - Add / Remove / Anchor + + /// `workspace.group.add` — add a workspace to a group. + func workspaceGroupAdd(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id"), + let wsId = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing group_id or workspace_id", data: nil) + } + let resolution = context?.controlAddWorkspaceToGroup( + routing: routingSelectors(params), + groupID: gid, + workspaceID: wsId + ) ?? .tabManagerUnavailable + let identity: JSONValue = .object([ + "group_id": .string(gid.uuidString), + "workspace_id": .string(wsId.uuidString), + ]) + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .added: + return .ok(identity) + case .notFound: + return .err(code: "not_found", message: "Group or workspace not found", data: identity) + case .workspaceIsOtherGroupAnchor: + return .err(code: "invalid_state", message: workspaceGroupStrings().workspaceIsOtherGroupAnchor, data: identity) + } + } + + /// `workspace.group.remove` — remove a workspace from its group. + func workspaceGroupRemove(_ params: [String: JSONValue]) -> ControlCallResult { + guard let wsId = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let ok = context?.controlRemoveWorkspaceFromGroup(routing: routingSelectors(params), workspaceID: wsId) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return ok + ? .ok(.object(["workspace_id": .string(wsId.uuidString)])) + : .err(code: "not_found", message: "Workspace not in a group", data: .object(["workspace_id": .string(wsId.uuidString)])) + } + + /// `workspace.group.set_anchor` — set a group's anchor workspace. + func workspaceGroupSetAnchor(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id"), + let wsId = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing group_id or workspace_id", data: nil) + } + guard let ok = context?.controlSetWorkspaceGroupAnchor( + routing: routingSelectors(params), groupID: gid, workspaceID: wsId + ) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return ok + ? .ok(.object(["group_id": .string(gid.uuidString), "anchor_workspace_id": .string(wsId.uuidString)])) + : .err(code: "not_found", message: "Group not found or workspace not a member", data: .object([ + "group_id": .string(gid.uuidString), + "workspace_id": .string(wsId.uuidString), + ])) + } + + // MARK: - New workspace + + /// `workspace.group.new_workspace` — create a workspace in a group. + func workspaceGroupNewWorkspace(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + let resolution = context?.controlCreateWorkspaceInGroup( + routing: routingSelectors(params), + groupID: gid, + placementRaw: string(params, "placement") + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidPlacement(let raw): + return .err( + code: "invalid_params", + message: "placement must be one of: afterCurrent, top, end", + data: .object(["placement": .string(raw)]) + ) + case .notFound: + return .err(code: "not_found", message: "Group not found", data: .object(["group_id": .string(gid.uuidString)])) + case .created(let workspaceID): + return .ok(.object([ + "group_id": .string(gid.uuidString), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + } + } + + // MARK: - Color / Icon + + /// `workspace.group.set_color` — set or clear a group's custom color. + func workspaceGroupSetColor(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + // Accept "hex": null to clear the override, or omit it entirely. + let hex: String? = rawString(params, "hex").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let normalized: String? = (hex?.isEmpty == false) ? hex : nil + guard let ok = context?.controlSetWorkspaceGroupColor( + routing: routingSelectors(params), groupID: gid, hex: normalized + ) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return ok + ? .ok(.object(["group_id": .string(gid.uuidString), "custom_color": orNull(normalized)])) + : .err(code: "not_found", message: "Group not found", data: .object(["group_id": .string(gid.uuidString)])) + } + + /// `workspace.group.set_icon` — set or clear a group's custom icon. + func workspaceGroupSetIcon(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + let symbol: String? = rawString(params, "symbol").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let normalized: String? = (symbol?.isEmpty == false) ? symbol : nil + guard let result = context?.controlSetWorkspaceGroupIcon( + routing: routingSelectors(params), groupID: gid, symbol: normalized + ) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return result.found + ? .ok(.object(["group_id": .string(gid.uuidString), "icon_symbol": orNull(result.storedSymbol)])) + : .err(code: "not_found", message: "Group not found", data: .object(["group_id": .string(gid.uuidString)])) + } + + // MARK: - Move + + /// `workspace.group.move` — move a group to an absolute or relative position. + func workspaceGroupMove(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + guard let ok = context?.controlMoveWorkspaceGroup( + routing: routingSelectors(params), + groupID: gid, + toIndex: int(params, "to_index"), + beforeGroupID: uuid(params, "before_group_id"), + afterGroupID: uuid(params, "after_group_id") + ) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + return ok + ? .ok(.object(["group_id": .string(gid.uuidString)])) + : .err( + code: "invalid_params", + message: "Missing or unresolvable target position", + data: .object(["group_id": .string(gid.uuidString)]) + ) + } + + // MARK: - Focus + + /// `workspace.group.focus` — focus a group's window and select its anchor. + func workspaceGroupFocus(_ params: [String: JSONValue]) -> ControlCallResult { + guard let gid = uuid(params, "group_id") else { + return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) + } + let resolution = context?.controlFocusWorkspaceGroup(routing: routingSelectors(params), groupID: gid) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .notFound: + return .err(code: "not_found", message: "Group or anchor not found", data: .object(["group_id": .string(gid.uuidString)])) + case .focused(let anchorID): + return .ok(.object([ + "group_id": .string(gid.uuidString), + "anchor_workspace_id": .string(anchorID.uuidString), + "anchor_workspace_ref": ref(.workspace, anchorID), + ])) + } + } + + // MARK: - Local helpers + + + /// The localized workspace-group error strings, resolved by the app + /// conformance against the app bundle. + private func workspaceGroupStrings() -> ControlWorkspaceGroupStrings { + context?.controlWorkspaceGroupStrings() ?? ControlWorkspaceGroupStrings( + allChildrenAreAnchors: "", + workspaceIsOtherGroupAnchor: "" + ) + } + + /// A JSON array whose every element is a string, mapped to `[String]` + /// (mirrors the legacy `params["child_workspace_ids"] as? [String]` cast: + /// a single string or a mixed array fails and falls to the malformed-shape + /// branch). + private func stringArrayExact(_ value: JSONValue?) -> [String]? { + guard case .array(let elements)? = value else { return nil } + var out: [String] = [] + out.reserveCapacity(elements.count) + for element in elements { + guard case .string(let string) = element else { return nil } + out.append(string) + } + return out + } + + /// Whether a JSON value is `null`. + private func isNull(_ value: JSONValue) -> Bool { + if case .null = value { return true } + return false + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift index 7096fc1c28e6..642a1db6975c 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift @@ -67,6 +67,9 @@ public final class ControlCommandCoordinator { if let result = handleAppFocus(request) { return result } if let result = handleFeed(request) { return result } if let result = handleNotification(request) { return result } + if let result = handleWorkspaceGroup(request) { return result } + if let result = handlePane(request) { return result } + if let result = handleMobileHost(request) { return result } return nil } diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMobileHostContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMobileHostContext.swift new file mode 100644 index 000000000000..44acae7c1fd7 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMobileHostContext.swift @@ -0,0 +1,103 @@ +internal import Foundation + +/// The mobile-host-domain slice of the control-command seam (a constituent of +/// the ``ControlCommandContext`` umbrella). +/// +/// This domain serves the `mobile.*` / `terminal.*` methods the Mac exposes to +/// the paired iOS client through the v2 control socket: host status, the +/// mobile-shaped workspace/terminal list, terminal create, and the terminal +/// input / replay / viewport / scroll / mouse data-plane verbs. +/// +/// Unlike the window domain, these bodies build deeply nested, +/// app-state-derived payloads (render grids, per-workspace terminal lists, +/// viewport state-machine mutations) and resolve their target through the +/// legacy `v2ResolveTabManager` / `v2ResolveWorkspace` precedence. Re-modeling +/// every leaf as a typed snapshot would be a large, error-prone surface for a +/// faithful lift, and none of these payloads mint `kind:N` refs (every id is a +/// raw `uuidString`). So each seam method takes the coordinator's already-typed +/// params and returns a fully-built ``ControlCallResult``: the app conformance +/// runs the EXACT legacy body against live `AppDelegate` / `TabManager` / +/// `Workspace` / `MobileHostService` state and bridges its Foundation payload to +/// a ``JSONValue`` (lossless via `JSONValue(foundationObject:)`), so the encoded +/// wire bytes match byte-for-byte. +/// +/// Building the result app-side also keeps the localized error strings +/// (`socket.terminal.processExited`, `socket.terminal.inputQueueFull`, +/// `socket.terminal.surfaceUnavailable`) resolving against the app bundle — if +/// the coordinator built them with `String(localized:)` they would bind to the +/// package bundle, which lacks those keys, and silently drop the non-English +/// translations (a wire change). +/// +/// Every method is `@MainActor` because its conformer and the coordinator both +/// live on the main actor, so these are plain in-isolation calls — the per-read +/// `v2MainSync` hops the legacy command bodies used disappear once the domain +/// moves onto the coordinator. +/// +/// The worker-lane mobile method (`mobile.attach_ticket.create`) blocks/awaits +/// on the socket worker and is deliberately NOT part of this seam; it stays on +/// the app-side worker path. So do the DEBUG-only and mobile-data-plane-only +/// verbs (`mobile.dev_stack_auth.configure`, `mobile.terminal.paste_image`, +/// `workspace.create`/`workspace.action` mobile wrappers, `dogfood.feedback.*`), +/// which are dispatched only from the mobile RPC handler, not `processV2Command`. +@MainActor +public protocol ControlMobileHostContext: AnyObject { + /// `mobile.host.status` — host identity, route status, advertised + /// capabilities, and the resolved workspace count (the `processV2Command` + /// path includes private metadata, matching the legacy default argument). + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileHostStatus(params: [String: JSONValue]) -> ControlCallResult + + /// `mobile.workspace.list` — the iOS-facing workspace/terminal list, scoped + /// to a single window when a target selector is present and flattened across + /// every main window otherwise. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileWorkspaceList(params: [String: JSONValue]) -> ControlCallResult + + /// `mobile.terminal.create` / `terminal.create` — create a terminal surface + /// in the resolved workspace, then echo the mobile workspace list with the + /// new terminal id. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileTerminalCreate(params: [String: JSONValue]) -> ControlCallResult + + /// `mobile.terminal.input` / `terminal.input` — forward typed text to the + /// resolved terminal surface, applying any piggybacked viewport report. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileTerminalInput(params: [String: JSONValue]) -> ControlCallResult + + /// `mobile.terminal.replay` / `terminal.replay` — the cold-attach replay + /// anchor (render-grid frame or VT/byte snapshot) for the resolved surface. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileTerminalReplay(params: [String: JSONValue]) -> ControlCallResult + + /// `mobile.terminal.viewport` / `terminal.viewport` — record or clear a + /// device's reported grid, recompute the shared minimum, cap the surface, + /// and echo the effective grid. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileTerminalViewport(params: [String: JSONValue]) -> ControlCallResult + + /// `mobile.terminal.scroll` / `terminal.scroll` — forward a phone scroll + /// gesture to the resolved surface. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileTerminalScroll(params: [String: JSONValue]) -> ControlCallResult + + /// `mobile.terminal.mouse` / `terminal.mouse` — forward a phone tap to the + /// resolved surface as a click at the given cell. + /// + /// - Parameter params: The decoded request params. + /// - Returns: The fully-built command result. + func controlMobileTerminalMouse(params: [String: JSONValue]) -> ControlCallResult +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneBreakResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneBreakResolution.swift new file mode 100644 index 000000000000..60d530d53bea --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneBreakResolution.swift @@ -0,0 +1,38 @@ +public import Foundation + +/// The outcome of `pane.break`, preserving every distinct branch of the legacy +/// `v2PaneBreak` body and the new-workspace identity it echoes back. +/// +/// The seam resolves the source workspace/pane/surface, detaches the surface +/// into a new workspace, and returns this resolution; the coordinator shapes the +/// final `JSONValue`. +public enum ControlPaneBreakResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// A TabManager resolved but no workspace did (legacy `not_found` / + /// "Workspace not found", `data: nil`). + case workspaceNotFound + /// There was no surface to break out (legacy `not_found` / "No source + /// surface to break", `data: nil`). + case noSourceSurface + /// The resolved surface id did not exist in the workspace (legacy + /// `not_found` / "Surface not found", `data: {"surface_id": …}`). Carries + /// the surface id. + case surfaceNotFound(UUID) + /// Detaching the source surface failed (legacy `internal_error` / "Failed to + /// detach source surface", `data: nil`). + case detachFailed + /// Creating the destination workspace failed (legacy `internal_error` / + /// "Failed to create workspace for detached surface", `data: nil`). The + /// legacy body rolled the detached surface back before returning. + case createWorkspaceFailed + /// The destination pane could not be resolved after the move (legacy + /// `internal_error` / "Failed to resolve destination pane for detached + /// surface", `data: {"workspace_id": …, "surface_id": …}`). Carries the new + /// workspace id and the surface id. + case destinationPaneUnresolved(workspaceID: UUID, surfaceID: UUID) + /// The surface was broken out into a new workspace. Carries the echoed + /// identity (window may be absent; workspace, pane, and surface present). + case broken(windowID: UUID?, workspaceID: UUID, paneID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneContext.swift new file mode 100644 index 000000000000..09937da1cb6f --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneContext.swift @@ -0,0 +1,137 @@ +public import Foundation + +/// The pane-domain slice of the control-command seam (a constituent of the +/// ``ControlCommandContext`` umbrella). +/// +/// The app target (today `TerminalController`, the interim composition owner) +/// conforms by reading live `TabManager` / `Workspace` / `BonsplitController` +/// state and the Ghostty surfaces. Every method is `@MainActor` because its +/// conformer and the coordinator both live on the main actor, so these are plain +/// in-isolation calls — the per-read `v2MainSync` hops the legacy command bodies +/// used disappear once the domain moves onto the coordinator. +/// +/// No app types cross the seam: reads return `Control*` snapshot values and +/// mutations take pre-parsed selectors/ids and return small Sendable resolution +/// enums. The legacy `v2LocatePane` stays app-side (it returns `TabManager` / +/// `Workspace` / `PaneID`); the conformance calls it internally. +@MainActor +public protocol ControlPaneContext: AnyObject { + /// Snapshots the resolved workspace's pane layout for `pane.list`. + /// + /// - Parameter routing: The routing selectors for TabManager + workspace + /// resolution (legacy `v2ResolveTabManager` / `v2ResolveWorkspace`). + /// - Returns: The pane-list snapshot, or `nil` when no TabManager or + /// workspace resolves (the coordinator maps each to its legacy error). + func controlPaneList(routing: ControlRoutingSelectors) -> ControlPaneListSnapshot? + + /// Whether a TabManager resolves for `pane.list` / similar routing, used to + /// distinguish the `unavailable` failure from the `not_found` failure. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: Whether a TabManager resolved. + func controlPaneRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool + + /// Focuses the pane `paneID` in the resolved workspace for `pane.focus`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - paneID: The pane to focus. + /// - Returns: The focus resolution. + func controlPaneFocus( + routing: ControlRoutingSelectors, + paneID: UUID + ) -> ControlPaneFocusResolution + + /// Snapshots one pane's surfaces for `pane.surfaces`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - paneID: The explicit `pane_id`, or `nil` to use the focused pane. + /// - Returns: The surfaces snapshot, or `nil` when no TabManager, workspace, + /// or pane resolves. + func controlPaneSurfaces( + routing: ControlRoutingSelectors, + paneID: UUID? + ) -> ControlPaneSurfacesSnapshot? + + /// Creates a split pane for `pane.create`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - inputs: The pre-parsed create inputs. + /// - Returns: The create resolution. + func controlPaneCreate( + routing: ControlRoutingSelectors, + inputs: ControlPaneCreateInputs + ) -> ControlPaneCreateResolution + + /// Resizes a pane for `pane.resize`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - inputs: The pre-parsed (and pre-validated) resize inputs. + /// - Returns: The resize resolution. + func controlPaneResize( + routing: ControlRoutingSelectors, + inputs: ControlPaneResizeInputs + ) -> ControlPaneResizeResolution + + /// Swaps two panes within a workspace for `pane.swap`. + /// + /// - Parameters: + /// - sourcePaneID: The source pane (located across windows via the legacy + /// `v2LocatePane`). + /// - targetPaneID: The target pane (must be in the source workspace). + /// - requestedFocus: Whether the request asked to focus the target pane + /// (the seam applies the socket focus-allowance gate). + /// - Returns: The swap resolution. + func controlPaneSwap( + sourcePaneID: UUID, + targetPaneID: UUID, + requestedFocus: Bool + ) -> ControlPaneSwapResolution + + /// Breaks a surface out into a new workspace for `pane.break`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - paneID: The explicit source `pane_id`, or `nil` for the focused pane. + /// - surfaceID: The explicit `surface_id`, or `nil` to derive it from the + /// source pane's selected surface (then the workspace's focused panel). + /// - requestedFocus: Whether to select the new workspace (the seam applies + /// the socket focus-allowance gate). + /// - Returns: The break resolution. + func controlPaneBreak( + routing: ControlRoutingSelectors, + paneID: UUID?, + surfaceID: UUID?, + requestedFocus: Bool + ) -> ControlPaneBreakResolution + + /// Joins a surface into a target pane for `pane.join`, delegating to the + /// shared surface-move logic. + /// + /// - Parameters: + /// - targetPaneID: The destination pane. + /// - surfaceID: The explicit `surface_id` to move, or `nil` to derive it + /// from `sourcePaneID`'s selected surface. + /// - sourcePaneID: The source `pane_id`, used only when `surfaceID` is + /// `nil`. + /// - hasFocusParam: Whether the request carried a `focus` param (forwarded + /// to the surface-move call only when present, as the legacy body did). + /// - focus: The `focus` param value (used only when `hasFocusParam`). + /// - Returns: The join resolution. + func controlPaneJoin( + targetPaneID: UUID, + surfaceID: UUID?, + sourcePaneID: UUID?, + hasFocusParam: Bool, + focus: Bool + ) -> ControlPaneJoinResolution + + /// Focuses the alternate pane for `pane.last`. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: The last-pane resolution. + func controlPaneLast(routing: ControlRoutingSelectors) -> ControlPaneLastResolution +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateInputs.swift new file mode 100644 index 000000000000..b5e8494d6f15 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateInputs.swift @@ -0,0 +1,82 @@ +public import Foundation + +/// The pre-parsed primitive inputs `pane.create` carries, as +/// ``ControlCommandCoordinator`` hands them to ``ControlPaneContext``. +/// +/// The coordinator parses each value from the request params with its helpers +/// (mirroring the legacy `v2*` parsing exactly); the seam runs the app-coupled +/// work (direction parsing, panel-type resolution, browser availability, the +/// split creation). No app types cross the seam. +public struct ControlPaneCreateInputs: Sendable, Equatable { + /// The trimmed `direction` string, if present (legacy `v2String`). The seam + /// parses it to a split direction and rejects a missing or unknown value. + public let directionRaw: String? + /// The trimmed `type` string, if present (legacy `v2String`). The seam + /// resolves it to a panel type, defaulting to terminal when absent/unknown. + public let typeRaw: String? + /// The raw `url` string, if present (legacy `v2String`), used for the URL + /// and the browser-disabled error data. + public let urlRaw: String? + /// The trimmed-non-empty `working_directory`, if any (legacy + /// `v2OptionalTrimmedRawString`). + public let workingDirectory: String? + /// The trimmed-non-empty `initial_command`, if any. + public let initialCommand: String? + /// The trimmed-non-empty `tmux_start_command`, if any. + public let tmuxStartCommand: String? + /// The startup environment map (legacy `v2TrimmedStringMap` over + /// `startup_environment` / `initial_env`). + public let startupEnvironment: [String: String] + /// The requested source surface id (legacy `v2String("surface_id")` parsed + /// as a UUID string), if any. + public let requestedSourceSurfaceID: UUID? + /// Whether the request asked to focus the new pane (legacy `v2Bool`, + /// defaulting to false). The seam applies the socket focus-allowance gate. + public let requestedFocus: Bool + /// Whether an `initial_divider_position` param was present and non-null + /// (legacy `v2HasNonNullParam`), so the seam knows to validate it. + public let hasInitialDividerPosition: Bool + /// The raw `initial_divider_position` value, if present (legacy `v2Double`, + /// pre-clamp). The seam validates finiteness and clamps to `[0.1, 0.9]`. + public let initialDividerPositionRaw: Double? + + /// Creates the pane-create inputs. + /// + /// - Parameters: + /// - directionRaw: The trimmed `direction` string, if present. + /// - typeRaw: The trimmed `type` string, if present. + /// - urlRaw: The raw `url` string, if present. + /// - workingDirectory: The trimmed-non-empty working directory, if any. + /// - initialCommand: The trimmed-non-empty initial command, if any. + /// - tmuxStartCommand: The trimmed-non-empty tmux start command, if any. + /// - startupEnvironment: The startup environment map. + /// - requestedSourceSurfaceID: The requested source surface id, if any. + /// - requestedFocus: Whether to focus the new pane. + /// - hasInitialDividerPosition: Whether a divider param was present. + /// - initialDividerPositionRaw: The raw divider value, if present. + public init( + directionRaw: String?, + typeRaw: String?, + urlRaw: String?, + workingDirectory: String?, + initialCommand: String?, + tmuxStartCommand: String?, + startupEnvironment: [String: String], + requestedSourceSurfaceID: UUID?, + requestedFocus: Bool, + hasInitialDividerPosition: Bool, + initialDividerPositionRaw: Double? + ) { + self.directionRaw = directionRaw + self.typeRaw = typeRaw + self.urlRaw = urlRaw + self.workingDirectory = workingDirectory + self.initialCommand = initialCommand + self.tmuxStartCommand = tmuxStartCommand + self.startupEnvironment = startupEnvironment + self.requestedSourceSurfaceID = requestedSourceSurfaceID + self.requestedFocus = requestedFocus + self.hasInitialDividerPosition = hasInitialDividerPosition + self.initialDividerPositionRaw = initialDividerPositionRaw + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateResolution.swift new file mode 100644 index 000000000000..1773ee1db6c5 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateResolution.swift @@ -0,0 +1,62 @@ +public import Foundation + +/// The outcome of `pane.create`, preserving every distinct branch of the legacy +/// `v2PaneCreate` body — including its delegation to the browser-disabled +/// external-open path — and the created-pane identity it echoes back. +/// +/// The coordinator parses the primitive inputs (direction string, type string, +/// url, working directory, commands, environment, initial divider) and shapes +/// the final `JSONValue`; the seam runs all the app-coupled work (panel-type +/// resolution, browser-availability check, the split creation) and returns this. +public enum ControlPaneCreateResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The `direction` was missing or not one of `left|right|up|down` (legacy + /// `invalid_params` / "Missing or invalid direction (left|right|up|down)", + /// `data: nil`). + case invalidDirection + /// The `initial_divider_position` was present but non-numeric (legacy + /// `invalid_params` / "initial_divider_position must be numeric", `data: + /// nil`). + case invalidDividerPosition + /// The `type` resolved to `agent-session`, which `pane.create` rejects + /// (legacy `invalid_params` / "agent-session is only supported by + /// surface.create", `data: {"type": rawValue}`). Carries the raw type value. + case agentSessionRejected(typeRawValue: String) + /// A browser split was requested while the cmux browser is disabled and an + /// invalid URL was supplied (legacy `invalid_params` / "Invalid URL", + /// `data: {"url": rawURL}`). Carries the raw URL string. + case browserDisabledInvalidURL(rawURL: String) + /// A browser split was requested while the cmux browser is disabled and no + /// URL was supplied (legacy `browser_disabled` / "cmux browser is + /// disabled", `data: nil`). + case browserDisabledNoURL + /// A browser split was requested while the cmux browser is disabled and the + /// external open failed (legacy `external_open_failed` / "Failed to open URL + /// externally", `data: {"url": absoluteString}`). Carries the URL string. + case browserDisabledExternalOpenFailed(url: String) + /// A browser split was requested while the cmux browser is disabled and the + /// URL opened externally (legacy `ok`, the external-open payload). Carries + /// the resolved window (may be absent) and the opened URL string. + case browserDisabledOpenedExternally(windowID: UUID?, url: String) + /// A TabManager resolved but no workspace did (legacy `not_found` / + /// "Workspace not found", `data: nil`). + case workspaceNotFound + /// No source surface to split from (legacy `not_found` / "No source surface + /// to split", `data: nil`). + case noSourceSurface + /// The split creation failed (legacy `internal_error` / "Failed to create + /// pane", `data: nil`). + case createFailed + /// The pane was created. Carries the echoed identity (window and pane may be + /// absent; workspace and the new surface are present) and the resolved panel + /// type's raw value. + case created( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID?, + surfaceID: UUID, + typeRawValue: String + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneFocusResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneFocusResolution.swift new file mode 100644 index 000000000000..65c3ff875eea --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneFocusResolution.swift @@ -0,0 +1,22 @@ +public import Foundation + +/// The outcome of `pane.focus`, preserving the legacy body's distinct failures +/// and the focused identity it echoes back. +/// +/// The coordinator validates `pane_id` (returning `invalid_params` itself) and +/// signals `unavailable` when no seam is wired; the seam resolves the workspace +/// and pane, focuses, and returns this resolution. +public enum ControlPaneFocusResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// A TabManager resolved but no workspace did (legacy `not_found` / + /// "Workspace not found", `data: nil`). + case workspaceNotFound + /// The pane id did not match any pane in the workspace (legacy `not_found` / + /// "Pane not found", `data: {"pane_id": …}`). Carries the unresolved id. + case paneNotFound(UUID) + /// The pane was focused. Carries the echoed identity (window may be absent; + /// workspace and pane are present). + case focused(windowID: UUID?, workspaceID: UUID, paneID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneGridSize.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneGridSize.swift new file mode 100644 index 000000000000..a833ed5af81a --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneGridSize.swift @@ -0,0 +1,30 @@ +/// The terminal grid size of a pane's selected surface, as the app target +/// exposes it to ``ControlCommandCoordinator`` for the `pane.list` payload. +/// +/// Read from the selected surface's live Ghostty surface (only present when the +/// surface is live and reports positive dimensions, matching the legacy body's +/// guard). The coordinator emits each field as a JSON integer. +public struct ControlPaneGridSize: Sendable, Equatable { + /// The number of columns. + public let columns: Int + /// The number of rows. + public let rows: Int + /// The cell width, in pixels. + public let cellWidthPx: Int + /// The cell height, in pixels. + public let cellHeightPx: Int + + /// Creates a grid size. + /// + /// - Parameters: + /// - columns: The number of columns. + /// - rows: The number of rows. + /// - cellWidthPx: The cell width, in pixels. + /// - cellHeightPx: The cell height, in pixels. + public init(columns: Int, rows: Int, cellWidthPx: Int, cellHeightPx: Int) { + self.columns = columns + self.rows = rows + self.cellWidthPx = cellWidthPx + self.cellHeightPx = cellHeightPx + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneJoinResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneJoinResolution.swift new file mode 100644 index 000000000000..38372161d56f --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneJoinResolution.swift @@ -0,0 +1,24 @@ +public import Foundation + +/// The outcome of `pane.join`, preserving the legacy `v2PaneJoin` body's +/// surface-resolution failures and its delegation to the surface-move path. +/// +/// The coordinator validates `target_pane_id` (returning `invalid_params` +/// itself); the seam resolves the surface to move (from an explicit +/// `surface_id`, or the selected surface of the source `pane_id`) and forwards +/// to the same surface-move logic `surface.move` uses, returning its result. +public enum ControlPaneJoinResolution: Sendable, Equatable { + /// A `pane_id` was given without a `surface_id`, but its selected surface + /// could not be resolved (legacy `not_found` / "Unable to resolve selected + /// surface in source pane", `data: {"pane_id": …}`). Carries the source + /// pane id. + case sourceSurfaceUnresolved(sourcePaneID: UUID) + /// Neither a `surface_id` nor a `pane_id` with a selected surface was given + /// (legacy `invalid_params` / "Missing surface_id (or pane_id with selected + /// surface)", `data: nil`). + case missingSurface + /// The surface resolved and the move was attempted; carries the surface-move + /// result verbatim (the legacy `v2SurfaceMove` return), which the + /// coordinator returns unchanged. + case moved(ControlCallResult) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneLastResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneLastResolution.swift new file mode 100644 index 000000000000..b8ea03b072ab --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneLastResolution.swift @@ -0,0 +1,21 @@ +public import Foundation + +/// The outcome of `pane.last`, preserving the legacy body's distinct failures +/// and the focused alternate-pane identity it echoes back. +public enum ControlPaneLastResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// A TabManager resolved but no workspace did (legacy `not_found` / + /// "Workspace not found", `data: nil`). + case workspaceNotFound + /// The workspace had no focused pane (legacy `not_found` / "No focused + /// pane", `data: nil`). + case noFocusedPane + /// There was no pane other than the focused one (legacy `not_found` / "No + /// alternate pane available", `data: nil`). + case noAlternatePane + /// The alternate pane was focused. Carries the echoed identity (window and + /// selected surface may be absent; workspace and pane are present). + case focused(windowID: UUID?, workspaceID: UUID, paneID: UUID, selectedSurfaceID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneListSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneListSnapshot.swift new file mode 100644 index 000000000000..f6f9687e9617 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneListSnapshot.swift @@ -0,0 +1,43 @@ +public import Foundation + +/// A read-only snapshot of a workspace's pane layout for `pane.list`, as the +/// app target exposes it to ``ControlCommandCoordinator``. +/// +/// Mirrors the legacy `v2PaneList` payload (workspace identity, the per-pane +/// rows, the resolved window, and the container frame) without the package +/// importing the app's workspace/Bonsplit types. The coordinator shapes the +/// final `JSONValue`, minting workspace/window refs itself. +public struct ControlPaneListSnapshot: Sendable, Equatable { + /// The resolved workspace's identifier. + public let workspaceID: UUID + /// The window the workspace belongs to, if resolved. + public let windowID: UUID? + /// The panes, in `allPaneIds` order. + public let panes: [ControlPaneSummary] + /// The container's width, in pixels. + public let containerWidth: Double + /// The container's height, in pixels. + public let containerHeight: Double + + /// Creates a pane-list snapshot. + /// + /// - Parameters: + /// - workspaceID: The resolved workspace's identifier. + /// - windowID: The window the workspace belongs to, if resolved. + /// - panes: The panes, in order. + /// - containerWidth: The container's width, in pixels. + /// - containerHeight: The container's height, in pixels. + public init( + workspaceID: UUID, + windowID: UUID?, + panes: [ControlPaneSummary], + containerWidth: Double, + containerHeight: Double + ) { + self.workspaceID = workspaceID + self.windowID = windowID + self.panes = panes + self.containerWidth = containerWidth + self.containerHeight = containerHeight + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPanePixelFrame.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPanePixelFrame.swift new file mode 100644 index 000000000000..0db78a53642e --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPanePixelFrame.swift @@ -0,0 +1,30 @@ +/// A pixel rectangle a pane occupies, as the app target exposes it to +/// ``ControlCommandCoordinator`` for the `pane.list` payload. +/// +/// Mirrors Bonsplit's `PixelRect` without the package importing Bonsplit. The +/// coordinator emits each field as a JSON number, byte-faithful to the legacy +/// `pixel_frame` dictionary (`Double` values). +public struct ControlPanePixelFrame: Sendable, Equatable { + /// The frame's origin x, in pixels. + public let x: Double + /// The frame's origin y, in pixels. + public let y: Double + /// The frame's width, in pixels. + public let width: Double + /// The frame's height, in pixels. + public let height: Double + + /// Creates a pixel frame. + /// + /// - Parameters: + /// - x: The origin x, in pixels. + /// - y: The origin y, in pixels. + /// - width: The width, in pixels. + /// - height: The height, in pixels. + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x + self.y = y + self.width = width + self.height = height + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeInputs.swift new file mode 100644 index 000000000000..cb388ab934c4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeInputs.swift @@ -0,0 +1,45 @@ +public import Foundation + +/// The pre-parsed inputs `pane.resize` carries, as ``ControlCommandCoordinator`` +/// hands them to ``ControlPaneContext``. +/// +/// The coordinator parses each value (mirroring the legacy `v2*` parsing) and +/// performs the present-but-invalid validation that returns `invalid_params`; +/// the seam runs the split-tree candidate collection and the divider mutation. +public struct ControlPaneResizeInputs: Sendable, Equatable { + /// The explicit `pane_id` target, if any; the seam falls back to the focused + /// pane when absent. + public let paneID: UUID? + /// The lowercased `absolute_axis` (`horizontal`/`vertical`), if the request + /// took the absolute-resize path. + public let absoluteAxis: String? + /// The `target_pixels` for the absolute-resize path, if present. + public let targetPixels: Double? + /// The lowercased `direction` (`left|right|up|down`), if the request took the + /// relative-resize path. + public let direction: String? + /// The relative-resize `amount` (defaulting to 1, as the legacy body did). + public let amount: Int + + /// Creates the pane-resize inputs. + /// + /// - Parameters: + /// - paneID: The explicit `pane_id` target, if any. + /// - absoluteAxis: The lowercased absolute axis, if present. + /// - targetPixels: The absolute target pixels, if present. + /// - direction: The lowercased relative direction, if present. + /// - amount: The relative amount. + public init( + paneID: UUID?, + absoluteAxis: String?, + targetPixels: Double?, + direction: String?, + amount: Int + ) { + self.paneID = paneID + self.absoluteAxis = absoluteAxis + self.targetPixels = targetPixels + self.direction = direction + self.amount = amount + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeResolution.swift new file mode 100644 index 000000000000..82144a6ef715 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeResolution.swift @@ -0,0 +1,70 @@ +public import Foundation + +/// The outcome of `pane.resize`, preserving every distinct branch of the legacy +/// `v2PaneResize` body — both the absolute and the relative resize paths, with +/// their separate success payloads. +/// +/// The coordinator performs the pre-flight `invalid_params` validation (the +/// absolute-axis / target-pixels / direction checks that do not touch app +/// state) and shapes the final `JSONValue`; the seam runs the split-tree +/// candidate collection and divider mutation and returns this resolution. +public enum ControlPaneResizeResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// A TabManager resolved but no workspace did (legacy `not_found` / + /// "Workspace not found", `data: nil`). + case workspaceNotFound + /// No `pane_id` was given and the workspace had no focused pane (legacy + /// `not_found` / "No focused pane", `data: nil`). + case noFocusedPane + /// The pane id did not match any pane (legacy `not_found` / "Pane not + /// found", `data: {"pane_id": …}`). Carries the unresolved pane id. + case paneNotFound(UUID) + /// The pane was not found in the split tree (legacy `not_found` / "Pane not + /// found in split tree", `data: {"pane_id": …}`). Carries the pane id. + case paneNotFoundInTree(UUID) + /// The absolute resize found no split ancestor (legacy `invalid_state` / "No + /// split ancestor for absolute pane resize", `data: {"pane_id": …, + /// "absolute_axis": axis-or-null}`). Carries the pane id and axis (axis may + /// be absent, matching the legacy `v2OrNull`). + case noAbsoluteSplitAncestor(paneID: UUID, absoluteAxis: String?) + /// The relative resize found no split ancestor in the requested orientation + /// (legacy `invalid_state` / "No split ancestor for pane", + /// `data: {"pane_id": …, "direction": …}`). Carries the pane id, + /// orientation, and direction. + case noOrientationSplitAncestor(paneID: UUID, orientation: String, direction: String) + /// The pane has no adjacent border in the requested direction (legacy + /// `invalid_state` / "Pane has no adjacent border in direction ", + /// `data: {"pane_id": …, "direction": …}`). Carries the pane id and + /// direction. + case noAdjacentBorder(paneID: UUID, direction: String) + /// Setting the split divider position failed (legacy `internal_error` / + /// "Failed to set split divider position", `data: {"split_id": …}`). Carries + /// the split id. + case setDividerFailed(splitID: UUID) + /// The absolute resize succeeded. Carries the echoed identity plus the + /// split, axis, target pixels, and old/new divider positions. + case absoluteResized( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID, + splitID: UUID, + absoluteAxis: String, + targetPixels: Double, + oldDividerPosition: Double, + newDividerPosition: Double + ) + /// The relative resize succeeded. Carries the echoed identity plus the + /// split, direction, amount, and old/new divider positions. + case relativeResized( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID, + splitID: UUID, + direction: String, + amount: Int, + oldDividerPosition: Double, + newDividerPosition: Double + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSummary.swift new file mode 100644 index 000000000000..02543299fcb3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSummary.swift @@ -0,0 +1,48 @@ +public import Foundation + +/// A read-only snapshot of one pane in a workspace, as the app target exposes +/// it to ``ControlCommandCoordinator`` for the `pane.list` payload row. +/// +/// Mirrors the legacy per-pane dictionary the `v2PaneList` body built from the +/// workspace's `bonsplitController`, without the package importing the app's +/// pane/Bonsplit types. The coordinator turns each summary into one row, minting +/// the pane/surface refs itself. +public struct ControlPaneSummary: Sendable, Equatable { + /// The pane's stable identifier. + public let paneID: UUID + /// Whether this pane currently holds focus. + public let isFocused: Bool + /// The surfaces in this pane, in tab order. + public let surfaceIDs: [UUID] + /// The selected surface in this pane, if any. + public let selectedSurfaceID: UUID? + /// The pane's pixel frame, if the layout snapshot reported one. + public let pixelFrame: ControlPanePixelFrame? + /// The selected surface's terminal grid size, if it is live and reporting. + public let gridSize: ControlPaneGridSize? + + /// Creates a pane summary. + /// + /// - Parameters: + /// - paneID: The pane's stable identifier. + /// - isFocused: Whether this pane holds focus. + /// - surfaceIDs: The surfaces in the pane, in tab order. + /// - selectedSurfaceID: The selected surface, if any. + /// - pixelFrame: The pane's pixel frame, if known. + /// - gridSize: The selected surface's grid size, if available. + public init( + paneID: UUID, + isFocused: Bool, + surfaceIDs: [UUID], + selectedSurfaceID: UUID?, + pixelFrame: ControlPanePixelFrame?, + gridSize: ControlPaneGridSize? + ) { + self.paneID = paneID + self.isFocused = isFocused + self.surfaceIDs = surfaceIDs + self.selectedSurfaceID = selectedSurfaceID + self.pixelFrame = pixelFrame + self.gridSize = gridSize + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfaceSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfaceSummary.swift new file mode 100644 index 000000000000..e8d76a6515ec --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfaceSummary.swift @@ -0,0 +1,39 @@ +public import Foundation + +/// A read-only snapshot of one surface (tab) within a pane, as the app target +/// exposes it to ``ControlCommandCoordinator`` for the `pane.surfaces` payload +/// row. +/// +/// Mirrors the legacy per-surface dictionary the `v2PaneSurfaces` body built. +/// The surface id and type are optional, matching the legacy `v2OrNull` writes +/// (a tab whose panel id can't be resolved, or a panel with no type). The +/// coordinator turns each summary into one row, minting the surface ref itself. +public struct ControlPaneSurfaceSummary: Sendable, Equatable { + /// The surface's panel identifier, if it resolved. + public let surfaceID: UUID? + /// The tab's title. + public let title: String + /// The panel type's raw value, if the panel resolved. + public let typeRawValue: String? + /// Whether this surface is the selected tab in its pane. + public let isSelected: Bool + + /// Creates a pane-surface summary. + /// + /// - Parameters: + /// - surfaceID: The surface's panel identifier, if resolved. + /// - title: The tab's title. + /// - typeRawValue: The panel type's raw value, if resolved. + /// - isSelected: Whether this surface is selected. + public init( + surfaceID: UUID?, + title: String, + typeRawValue: String?, + isSelected: Bool + ) { + self.surfaceID = surfaceID + self.title = title + self.typeRawValue = typeRawValue + self.isSelected = isSelected + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfacesSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfacesSnapshot.swift new file mode 100644 index 000000000000..bba817dee154 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfacesSnapshot.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// A read-only snapshot of one pane's surfaces for `pane.surfaces`, as the app +/// target exposes it to ``ControlCommandCoordinator``. +/// +/// Mirrors the legacy `v2PaneSurfaces` payload (workspace + pane identity, the +/// per-surface rows, and the resolved window). The coordinator shapes the final +/// `JSONValue`, minting workspace/pane/window refs itself. +public struct ControlPaneSurfacesSnapshot: Sendable, Equatable { + /// The resolved workspace's identifier. + public let workspaceID: UUID + /// The pane the surfaces belong to. + public let paneID: UUID + /// The window the workspace belongs to, if resolved. + public let windowID: UUID? + /// The surfaces in the pane, in tab order. + public let surfaces: [ControlPaneSurfaceSummary] + + /// Creates a pane-surfaces snapshot. + /// + /// - Parameters: + /// - workspaceID: The resolved workspace's identifier. + /// - paneID: The pane the surfaces belong to. + /// - windowID: The window the workspace belongs to, if resolved. + /// - surfaces: The surfaces in the pane, in order. + public init( + workspaceID: UUID, + paneID: UUID, + windowID: UUID?, + surfaces: [ControlPaneSurfaceSummary] + ) { + self.workspaceID = workspaceID + self.paneID = paneID + self.windowID = windowID + self.surfaces = surfaces + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSwapResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSwapResolution.swift new file mode 100644 index 000000000000..1e84aedcbdfd --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSwapResolution.swift @@ -0,0 +1,46 @@ +public import Foundation + +/// The outcome of `pane.swap`, preserving every distinct branch of the legacy +/// `v2PaneSwap` body and the swapped identity it echoes back. +/// +/// The coordinator validates `pane_id`/`target_pane_id` (returning +/// `invalid_params` itself); the seam locates the panes, performs the swap +/// (with stable-identity placeholders), and returns this resolution. +public enum ControlPaneSwapResolution: Sendable, Equatable { + /// The source pane was not found (legacy `not_found` / "Source pane not + /// found", `data: {"pane_id": …}`). Carries the source pane id. + case sourcePaneNotFound(UUID) + /// The target pane was not found in the source workspace (legacy `not_found` + /// / "Target pane not found in source workspace", `data: {"target_pane_id": + /// …}`). Carries the target pane id. + case targetPaneNotFound(UUID) + /// One or both panes had no selected surface (legacy `invalid_state` / "Both + /// panes must have a selected surface", `data: nil`). + case bothPanesNeedSurface + /// Creating the source-pane stability placeholder failed (legacy + /// `internal_error` / "Failed to create source placeholder surface", `data: + /// nil`). + case sourcePlaceholderFailed + /// Creating the target-pane stability placeholder failed (legacy + /// `internal_error` / "Failed to create target placeholder surface", `data: + /// nil`). + case targetPlaceholderFailed + /// Moving the source surface into the target pane failed (legacy + /// `internal_error` / "Failed moving source surface into target pane", + /// `data: nil`). + case moveSourceFailed + /// Moving the target surface into the source pane failed (legacy + /// `internal_error` / "Failed moving target surface into source pane", + /// `data: nil`). + case moveTargetFailed + /// The swap succeeded. Carries the echoed window/workspace/pane/surface + /// identity for both sides. + case swapped( + windowID: UUID, + workspaceID: UUID, + sourcePaneID: UUID, + targetPaneID: UUID, + sourceSurfaceID: UUID, + targetSurfaceID: UUID + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupAddResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupAddResolution.swift new file mode 100644 index 000000000000..f893fc07443d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupAddResolution.swift @@ -0,0 +1,24 @@ +public import Foundation + +/// The outcome of `workspace.group.add`, preserving the legacy body's two +/// distinct failures. +/// +/// `addWorkspaceToGroup` silently no-ops for a workspace that is the anchor of +/// another group, so the legacy body confirmed membership actually changed and +/// distinguished that case (an `invalid_state` with a localized message) from +/// the generic `not_found`. The coordinator builds the error envelopes and the +/// `{group_id, workspace_id}` data; the seam signals which failure occurred. +public enum ControlWorkspaceGroupAddResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The workspace was added to the group. + case added + /// The group or workspace was not found, or the add no-op'd for an + /// unspecified reason (legacy `not_found` / "Group or workspace not found"). + case notFound + /// The workspace is the anchor of another group, so it can't join this one + /// until ungrouped (legacy `invalid_state` / + /// `workspaceGroup.error.workspaceIsOtherGroupAnchor`). + case workspaceIsOtherGroupAnchor +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupContext.swift new file mode 100644 index 000000000000..b5386766ded6 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupContext.swift @@ -0,0 +1,246 @@ +public import Foundation + +/// The workspace-group-domain slice of the control-command seam (a constituent +/// of the ``ControlCommandContext`` umbrella). +/// +/// The app target (today `TerminalController`, the interim composition owner) +/// conforms by resolving a `TabManager` from the routing selectors (the legacy +/// `v2ResolveTabManager` precedence) and reading/mutating its `WorkspaceGroup`s. +/// Every method is `@MainActor` because its conformer and the coordinator both +/// live on the main actor, so these are plain in-isolation calls — the per-read +/// `v2MainSync` hops the legacy command bodies used disappear once the domain +/// moves onto the coordinator. +/// +/// No app types (`TabManager` / `WorkspaceGroup` / `AppDelegate`) cross the +/// seam: each method takes pre-parsed selectors/ids and returns Sendable +/// snapshots, resolution enums, Bools, or optionals keyed by those ids. The two +/// localized error messages are supplied through ``ControlWorkspaceGroupStrings`` +/// so they resolve against the app bundle. +@MainActor +public protocol ControlWorkspaceGroupContext: AnyObject { + /// The localized workspace-group error messages, resolved against the app + /// bundle so the coordinator can shape the two localized error envelopes + /// (`allChildrenAreAnchors`, `workspaceIsOtherGroupAnchor`) without binding + /// `String(localized:)` to the package bundle. + func controlWorkspaceGroupStrings() -> ControlWorkspaceGroupStrings + + /// Snapshots every workspace group for `workspace.group.list`, with the + /// owning window id (the coordinator mints the window/group refs). + /// + /// - Parameter routing: The routing selectors used for TabManager + /// resolution. + /// - Returns: The list resolution. + func controlWorkspaceGroupList( + routing: ControlRoutingSelectors + ) -> ControlWorkspaceGroupListResolution + + /// Creates a workspace group for `workspace.group.create`. + /// + /// The coordinator has already parsed `name` / `cwd`, resolved the child + /// handles to UUIDs, and surfaced the param-shape `invalid_params` failures; + /// this runs the live-state remainder (fallback child selection, the + /// target-window existence check, the all-children-are-anchors guard, and + /// the create call). + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution and the + /// caller-workspace fallback. + /// - name: The group name (already defaulted to "" when absent). + /// - cwd: The anchor working directory, if provided. + /// - childWorkspaceIDs: The resolved child workspace ids, in request order + /// (empty when none provided/resolved). + /// - childrenExplicit: Whether the caller explicitly listed + /// `child_workspace_ids` (drives the eligibility guard and disables the + /// fallback selection). + /// - Returns: The create resolution. + func controlCreateWorkspaceGroup( + routing: ControlRoutingSelectors, + name: String, + cwd: String?, + childWorkspaceIDs: [UUID], + childrenExplicit: Bool + ) -> ControlWorkspaceGroupCreateResolution + + /// Ungroups the group for `workspace.group.ungroup`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to ungroup. + /// - Returns: `true` if the group existed and was ungrouped, `nil` if no + /// TabManager resolved. + func controlUngroupWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID + ) -> Bool? + + /// Deletes the group for `workspace.group.delete`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to delete. + /// - Returns: The number of workspaces closed if the group existed, `-1` if + /// the group was not found, or `nil` if no TabManager resolved. + func controlDeleteWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID + ) -> Int? + + /// Renames the group for `workspace.group.rename`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to rename. + /// - name: The new name. + /// - Returns: `true` if the group existed and was renamed, `false` if not + /// found, or `nil` if no TabManager resolved. + func controlRenameWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + name: String + ) -> Bool? + + /// Sets the group's collapsed state for `workspace.group.collapse` / + /// `workspace.group.expand`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to update. + /// - isCollapsed: The new collapsed state. + /// - Returns: `true` if the group existed and was updated, `false` if not + /// found, or `nil` if no TabManager resolved. + func controlSetWorkspaceGroupCollapsed( + routing: ControlRoutingSelectors, + groupID: UUID, + isCollapsed: Bool + ) -> Bool? + + /// Sets the group's pinned state for `workspace.group.pin` / + /// `workspace.group.unpin`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to update. + /// - isPinned: The new pinned state. + /// - Returns: `true` if the group existed and was updated, `false` if not + /// found, or `nil` if no TabManager resolved. + func controlSetWorkspaceGroupPinned( + routing: ControlRoutingSelectors, + groupID: UUID, + isPinned: Bool + ) -> Bool? + + /// Adds a workspace to a group for `workspace.group.add`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The target group. + /// - workspaceID: The workspace to add. + /// - Returns: The add resolution. + func controlAddWorkspaceToGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + workspaceID: UUID + ) -> ControlWorkspaceGroupAddResolution + + /// Removes a workspace from its group for `workspace.group.remove`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - workspaceID: The workspace to remove. + /// - Returns: `true` if the workspace was in a group and was removed, + /// `false` if not in a group, or `nil` if no TabManager resolved. + func controlRemoveWorkspaceFromGroup( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> Bool? + + /// Sets the group's anchor workspace for `workspace.group.set_anchor`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to update. + /// - workspaceID: The member workspace to make the anchor. + /// - Returns: `true` if the group existed and the workspace is a member, + /// `false` otherwise, or `nil` if no TabManager resolved. + func controlSetWorkspaceGroupAnchor( + routing: ControlRoutingSelectors, + groupID: UUID, + workspaceID: UUID + ) -> Bool? + + /// Creates a new workspace in a group for `workspace.group.new_workspace`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The target group. + /// - placementRaw: The raw `placement` param (untrimmed), if present, so + /// the seam can do the single `WorkspaceGroupNewPlacement` parse and + /// resolve the per-cwd / global default. + /// - Returns: The new-workspace resolution. + func controlCreateWorkspaceInGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + placementRaw: String? + ) -> ControlWorkspaceGroupNewWorkspaceResolution + + /// Sets the group's custom color for `workspace.group.set_color`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to update. + /// - hex: The normalized hex override, or `nil` to clear it. + /// - Returns: `true` if the group existed and was updated, `false` if not + /// found, or `nil` if no TabManager resolved. + func controlSetWorkspaceGroupColor( + routing: ControlRoutingSelectors, + groupID: UUID, + hex: String? + ) -> Bool? + + /// Sets the group's custom icon for `workspace.group.set_icon`, returning the + /// stored symbol the app actually persisted. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to update. + /// - symbol: The normalized symbol, or `nil` to clear it. + /// - Returns: A pair of (`found`, `storedSymbol`) where `storedSymbol` is the + /// app-persisted symbol when found; `found` is `false` if the group was not + /// found; `nil` if no TabManager resolved. + func controlSetWorkspaceGroupIcon( + routing: ControlRoutingSelectors, + groupID: UUID, + symbol: String? + ) -> (found: Bool, storedSymbol: String?)? + + /// Moves a group for `workspace.group.move`, resolving the target via an + /// explicit `to_index` or a relative `before_group_id` / `after_group_id`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to move. + /// - toIndex: The explicit absolute target index, if provided. + /// - beforeGroupID: The peer to move before, if provided. + /// - afterGroupID: The peer to move after, if provided. + /// - Returns: `true` if the group existed and a target resolved, `false` + /// otherwise, or `nil` if no TabManager resolved. + func controlMoveWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + toIndex: Int?, + beforeGroupID: UUID?, + afterGroupID: UUID? + ) -> Bool? + + /// Focuses a group for `workspace.group.focus`: focuses the owning window, + /// makes its TabManager active, and selects the group anchor. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - groupID: The group to focus. + /// - Returns: The focus resolution. + func controlFocusWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID + ) -> ControlWorkspaceGroupFocusResolution +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupCreateResolution.swift new file mode 100644 index 000000000000..58e1c5ce3f46 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupCreateResolution.swift @@ -0,0 +1,33 @@ +public import Foundation + +/// The outcome of `workspace.group.create`, preserving the legacy body's +/// app-state failures and the created group it echoes back. +/// +/// The coordinator parses `name` / `cwd` / `child_workspace_ids` (resolving each +/// child through the handle registry) and surfaces the param-shape failures +/// (`invalid_params` for a malformed `child_workspace_ids` or unresolved +/// handles) itself. The remaining resolution depends on live app state — the +/// fallback selection when children are absent, the existence check against the +/// target window, the all-children-are-anchors eligibility guard, and the create +/// call — so it happens behind the seam and returns one of these cases. +public enum ControlWorkspaceGroupCreateResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// One or more requested children are syntactically valid UUIDs that don't + /// exist in the target window (legacy `not_found` / "Child workspace not + /// found in target window: …", `data: {"unknown_workspace_ids": …}`). + /// Carries the missing workspace id strings, in request order. + case childWorkspaceNotFound([String]) + /// Every explicitly-listed child is already a group anchor, so only an + /// anchor-only group could be created (legacy `invalid_state` / + /// `workspaceGroup.error.allChildrenAreAnchors`, `data: + /// {"ineligible_workspace_ids": …}`). Carries the ineligible workspace id + /// strings. + case allChildrenAreAnchors([String]) + /// The group could not be created (legacy `not_created` / "Group was not + /// created", `data: nil`). + case notCreated + /// The group was created. Carries its snapshot for the `group` payload. + case created(ControlWorkspaceGroupSnapshot) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupFocusResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupFocusResolution.swift new file mode 100644 index 000000000000..5c1cd61bfd10 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupFocusResolution.swift @@ -0,0 +1,19 @@ +public import Foundation + +/// The outcome of `workspace.group.focus`, preserving the legacy body's single +/// failure and the focused anchor it echoes back. +/// +/// The legacy body focused the owning window, made its TabManager active, then +/// selected the group anchor through `selectWorkspace` (so the selection side +/// effects fire). All of that is app state, so it runs behind the seam; the +/// coordinator mints the anchor workspace ref. +public enum ControlWorkspaceGroupFocusResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The group or its anchor workspace was not found (legacy `not_found` / + /// "Group or anchor not found", `data: {"group_id": …}`). + case notFound + /// The group's anchor was focused. Carries the anchor workspace id. + case focused(anchorWorkspaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupListResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupListResolution.swift new file mode 100644 index 000000000000..95fa12f58a7d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupListResolution.swift @@ -0,0 +1,16 @@ +public import Foundation + +/// The outcome of `workspace.group.list`, preserving the legacy body's single +/// failure and the resolved window the success echoes back. +/// +/// The legacy body resolved a TabManager from the routing params, snapshotted +/// every workspace group, then resolved the owning window id (which may be +/// absent). The coordinator mints the window/group refs. +public enum ControlWorkspaceGroupListResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The groups were snapshotted. Carries the owning window id (may be absent, + /// the legacy `v2OrNull` case) and the group snapshots in sidebar order. + case resolved(windowID: UUID?, groups: [ControlWorkspaceGroupSnapshot]) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupNewWorkspaceResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupNewWorkspaceResolution.swift new file mode 100644 index 000000000000..9d41016cb02d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupNewWorkspaceResolution.swift @@ -0,0 +1,24 @@ +public import Foundation + +/// The outcome of `workspace.group.new_workspace`, preserving the legacy body's +/// failures and the created workspace it echoes back. +/// +/// Placement resolution (explicit param, then the group's per-cwd config, then +/// the global default) and the create call depend on live app state, so they sit +/// behind the seam. The bad-placement `invalid_params` is surfaced here too so +/// the seam owns the single `WorkspaceGroupNewPlacement` parse. +public enum ControlWorkspaceGroupNewWorkspaceResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The `placement` param was present but not one of the accepted values + /// (legacy `invalid_params` / "placement must be one of: afterCurrent, top, + /// end", `data: {"placement": }`). Carries the raw placement string. + case invalidPlacement(String) + /// The group was not found (legacy `not_found` / "Group not found", `data: + /// {"group_id": …}`). + case notFound + /// The workspace was created in the group. Carries its id (the coordinator + /// mints the workspace ref). + case created(workspaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupSnapshot.swift new file mode 100644 index 000000000000..8232a91d7168 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupSnapshot.swift @@ -0,0 +1,59 @@ +public import Foundation + +/// A read-only snapshot of one workspace group, as the app target exposes it to +/// ``ControlCommandCoordinator`` through ``ControlWorkspaceGroupContext``. +/// +/// Mirrors the app target's `WorkspaceGroup` (plus its computed membership) +/// without the package importing the app target. The coordinator turns each +/// snapshot into the `workspace.group.*` group payload, minting the +/// `workspace_group` / `workspace` refs itself (the legacy +/// `v2WorkspaceGroupPayload` did the minting inline). +public struct ControlWorkspaceGroupSnapshot: Sendable, Equatable { + /// The group's stable identifier. + public let id: UUID + /// The group's display name. + public let name: String + /// Whether the group is collapsed in the sidebar. + public let isCollapsed: Bool + /// Whether the group is pinned. + public let isPinned: Bool + /// The anchor workspace's identifier. + public let anchorWorkspaceID: UUID + /// The group's custom color override, if any. + public let customColor: String? + /// The group's custom icon symbol, if any. + public let iconSymbol: String? + /// The group's member workspace identifiers, in tab order. + public let memberWorkspaceIDs: [UUID] + + /// Creates a workspace-group snapshot. + /// + /// - Parameters: + /// - id: The group's stable identifier. + /// - name: The group's display name. + /// - isCollapsed: Whether the group is collapsed. + /// - isPinned: Whether the group is pinned. + /// - anchorWorkspaceID: The anchor workspace's identifier. + /// - customColor: The custom color override, if any. + /// - iconSymbol: The custom icon symbol, if any. + /// - memberWorkspaceIDs: The member workspace identifiers, in tab order. + public init( + id: UUID, + name: String, + isCollapsed: Bool, + isPinned: Bool, + anchorWorkspaceID: UUID, + customColor: String?, + iconSymbol: String?, + memberWorkspaceIDs: [UUID] + ) { + self.id = id + self.name = name + self.isCollapsed = isCollapsed + self.isPinned = isPinned + self.anchorWorkspaceID = anchorWorkspaceID + self.customColor = customColor + self.iconSymbol = iconSymbol + self.memberWorkspaceIDs = memberWorkspaceIDs + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupStrings.swift new file mode 100644 index 000000000000..8db76d58de43 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupStrings.swift @@ -0,0 +1,32 @@ +/// The localized workspace-group error messages, supplied by the app +/// conformance so they resolve against the app's `Localizable.xcstrings`. +/// +/// The coordinator owns the error-envelope shaping (codes, data) but these two +/// messages must keep their existing keys + default values and their per-locale +/// translations. Resolving `String(localized:)` inside the package would bind to +/// the package bundle, which lacks these keys, silently dropping the non-English +/// variants — so the app passes the already-resolved strings across the seam +/// instead. (Reference pattern: ``ControlNotificationStrings``.) +public struct ControlWorkspaceGroupStrings: Sendable, Equatable { + /// `workspaceGroup.error.allChildrenAreAnchors` — "All requested children + /// are ineligible because they are already group anchors; ungroup them + /// first". + public let allChildrenAreAnchors: String + /// `workspaceGroup.error.workspaceIsOtherGroupAnchor` — "Workspace is the + /// anchor of another group; ungroup it first". + public let workspaceIsOtherGroupAnchor: String + + /// Creates the localized message bundle. + /// + /// - Parameters: + /// - allChildrenAreAnchors: The all-children-are-anchors message. + /// - workspaceIsOtherGroupAnchor: The workspace-is-other-group-anchor + /// message. + public init( + allChildrenAreAnchors: String, + workspaceIsOtherGroupAnchor: String + ) { + self.allChildrenAreAnchors = allChildrenAreAnchors + self.workspaceIsOtherGroupAnchor = workspaceIsOtherGroupAnchor + } +} diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 35cc8d58a196..3d63d50614cc 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -17,6 +17,46 @@ extension ControlFeedContext { func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] { [] } } +extension ControlPaneContext { + func controlPaneList(routing: ControlRoutingSelectors) -> ControlPaneListSnapshot? { nil } + func controlPaneRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool { false } + func controlPaneFocus( + routing: ControlRoutingSelectors, + paneID: UUID + ) -> ControlPaneFocusResolution { .tabManagerUnavailable } + func controlPaneSurfaces( + routing: ControlRoutingSelectors, + paneID: UUID? + ) -> ControlPaneSurfacesSnapshot? { nil } + func controlPaneCreate( + routing: ControlRoutingSelectors, + inputs: ControlPaneCreateInputs + ) -> ControlPaneCreateResolution { .tabManagerUnavailable } + func controlPaneResize( + routing: ControlRoutingSelectors, + inputs: ControlPaneResizeInputs + ) -> ControlPaneResizeResolution { .tabManagerUnavailable } + func controlPaneSwap( + sourcePaneID: UUID, + targetPaneID: UUID, + requestedFocus: Bool + ) -> ControlPaneSwapResolution { .sourcePaneNotFound(sourcePaneID) } + func controlPaneBreak( + routing: ControlRoutingSelectors, + paneID: UUID?, + surfaceID: UUID?, + requestedFocus: Bool + ) -> ControlPaneBreakResolution { .tabManagerUnavailable } + func controlPaneJoin( + targetPaneID: UUID, + surfaceID: UUID?, + sourcePaneID: UUID?, + hasFocusParam: Bool, + focus: Bool + ) -> ControlPaneJoinResolution { .missingSurface } + func controlPaneLast(routing: ControlRoutingSelectors) -> ControlPaneLastResolution { .tabManagerUnavailable } +} + extension ControlNotificationContext { func controlNotificationCreate( routing: ControlRoutingSelectors, @@ -69,3 +109,73 @@ extension ControlNotificationContext { ) } } + +extension ControlWorkspaceGroupContext { + func controlWorkspaceGroupStrings() -> ControlWorkspaceGroupStrings { + ControlWorkspaceGroupStrings(allChildrenAreAnchors: "", workspaceIsOtherGroupAnchor: "") + } + + func controlWorkspaceGroupList( + routing: ControlRoutingSelectors + ) -> ControlWorkspaceGroupListResolution { .tabManagerUnavailable } + + func controlCreateWorkspaceGroup( + routing: ControlRoutingSelectors, + name: String, + cwd: String?, + childWorkspaceIDs: [UUID], + childrenExplicit: Bool + ) -> ControlWorkspaceGroupCreateResolution { .tabManagerUnavailable } + + func controlUngroupWorkspaceGroup(routing: ControlRoutingSelectors, groupID: UUID) -> Bool? { nil } + func controlDeleteWorkspaceGroup(routing: ControlRoutingSelectors, groupID: UUID) -> Int? { nil } + func controlRenameWorkspaceGroup(routing: ControlRoutingSelectors, groupID: UUID, name: String) -> Bool? { nil } + func controlSetWorkspaceGroupCollapsed(routing: ControlRoutingSelectors, groupID: UUID, isCollapsed: Bool) -> Bool? { nil } + func controlSetWorkspaceGroupPinned(routing: ControlRoutingSelectors, groupID: UUID, isPinned: Bool) -> Bool? { nil } + + func controlAddWorkspaceToGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + workspaceID: UUID + ) -> ControlWorkspaceGroupAddResolution { .tabManagerUnavailable } + + func controlRemoveWorkspaceFromGroup(routing: ControlRoutingSelectors, workspaceID: UUID) -> Bool? { nil } + func controlSetWorkspaceGroupAnchor(routing: ControlRoutingSelectors, groupID: UUID, workspaceID: UUID) -> Bool? { nil } + + func controlCreateWorkspaceInGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + placementRaw: String? + ) -> ControlWorkspaceGroupNewWorkspaceResolution { .tabManagerUnavailable } + + func controlSetWorkspaceGroupColor(routing: ControlRoutingSelectors, groupID: UUID, hex: String?) -> Bool? { nil } + func controlSetWorkspaceGroupIcon(routing: ControlRoutingSelectors, groupID: UUID, symbol: String?) -> (found: Bool, storedSymbol: String?)? { nil } + + func controlMoveWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + toIndex: Int?, + beforeGroupID: UUID?, + afterGroupID: UUID? + ) -> Bool? { nil } + + func controlFocusWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID + ) -> ControlWorkspaceGroupFocusResolution { .tabManagerUnavailable } +} + +extension ControlMobileHostContext { + private var mobileHostStubResult: ControlCallResult { + .err(code: "unavailable", message: "", data: nil) + } + + func controlMobileHostStatus(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileWorkspaceList(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileTerminalCreate(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileTerminalInput(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileTerminalReplay(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileTerminalViewport(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileTerminalScroll(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } + func controlMobileTerminalMouse(params: [String: JSONValue]) -> ControlCallResult { mobileHostStubResult } +} diff --git a/Sources/TerminalController+ControlMobileHostContext.swift b/Sources/TerminalController+ControlMobileHostContext.swift new file mode 100644 index 000000000000..5b3666d7530d --- /dev/null +++ b/Sources/TerminalController+ControlMobileHostContext.swift @@ -0,0 +1,83 @@ +import CmuxControlSocket +import Foundation + +/// The mobile-host-domain witnesses are the byte-faithful bodies of the former +/// `v2Mobile*` dispatchers `processV2Command` routed. +/// +/// These payloads are deeply nested and app-state-derived (render grids, +/// per-workspace terminal lists, the viewport state machine) and resolve their +/// target through `v2ResolveTabManager` / `v2ResolveWorkspace`, and none of them +/// mint `kind:N` refs. So each witness reconstructs the legacy `[String: Any]` +/// params (`JSONValue.foundationObject` is the exact inverse of the bridging the +/// v2 dispatcher applied in `V2SocketRequest(bridging:)`), runs the existing +/// private body unchanged, and bridges the resulting `V2CallResult` to a +/// `ControlCallResult` — the encoded wire bytes are identical. +/// +/// Building the result here (in the app target) also keeps the localized +/// terminal-input error strings resolving against the app's +/// `Localizable.xcstrings`: the coordinator never calls `String(localized:)` for +/// this domain, so no non-English translation is dropped. +/// +/// Both the coordinator (`processV2Command`) and the mobile RPC handler +/// (`mobileHostHandleRPC`) drive the same private bodies, so the wire behavior is +/// shared across both entrypoints. +extension TerminalController: ControlMobileHostContext { + func controlMobileHostStatus(params: [String: JSONValue]) -> ControlCallResult { + // `processV2Command` called `v2MobileHostStatus(params:)` with the + // default `includePrivateMetadata: true`, so keep that here. + bridgeMobileResult(v2MobileHostStatus(params: foundationParams(params))) + } + + func controlMobileWorkspaceList(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileWorkspaceList(params: foundationParams(params))) + } + + func controlMobileTerminalCreate(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileTerminalCreate(params: foundationParams(params))) + } + + func controlMobileTerminalInput(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileTerminalInput(params: foundationParams(params))) + } + + func controlMobileTerminalReplay(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileTerminalReplay(params: foundationParams(params))) + } + + func controlMobileTerminalViewport(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileTerminalViewport(params: foundationParams(params))) + } + + func controlMobileTerminalScroll(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileTerminalScroll(params: foundationParams(params))) + } + + func controlMobileTerminalMouse(params: [String: JSONValue]) -> ControlCallResult { + bridgeMobileResult(v2MobileTerminalMouse(params: foundationParams(params))) + } + + /// Reconstructs the legacy `[String: Any]` params from the coordinator's + /// typed params. This is the exact inverse of the dispatcher's + /// `request.params.mapValues { $0.foundationObject }`, so the legacy body + /// receives the identical Foundation dictionary it always did. + private func foundationParams(_ params: [String: JSONValue]) -> [String: Any] { + params.mapValues(\.foundationObject) + } + + /// Bridges a legacy `V2CallResult` (Foundation-shaped payload) to the typed + /// `ControlCallResult`. The mobile bodies only build valid-JSON payloads, so + /// the bridge never fails; the empty-object / `nil` fallbacks keep the + /// conversion total. + private func bridgeMobileResult(_ result: V2CallResult) -> ControlCallResult { + switch result { + case let .ok(payload): + return .ok(JSONValue(foundationObject: payload) ?? .object([:])) + case let .err(code, message, data): + return .err( + code: code, + message: message, + data: data.flatMap { JSONValue(foundationObject: $0) } + ) + } + } +} diff --git a/Sources/TerminalController+ControlPaneContext.swift b/Sources/TerminalController+ControlPaneContext.swift new file mode 100644 index 000000000000..9e1db15494d7 --- /dev/null +++ b/Sources/TerminalController+ControlPaneContext.swift @@ -0,0 +1,611 @@ +import AppKit +import Bonsplit +import CmuxControlSocket +import Foundation + +/// The pane-domain witnesses are the byte-faithful bodies of the former +/// `v2Pane*` dispatchers, minus the per-read `v2MainSync` hop: the coordinator +/// already runs on the main actor inside the socket-command policy scope, so each +/// hop would re-apply the identical thread-local focus-allowance stack — a no-op. +/// +/// App-coupled resolution (`resolveTabManager(routing:)`, `v2LocatePane`, +/// `v2ResolveWindowId`, the Bonsplit layout, the split-resize candidate +/// collection) stays here; the seam exposes only Sendable snapshots and +/// resolution enums. +extension TerminalController: ControlPaneContext { + func controlPaneRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool { + resolveTabManager(routing: routing) != nil + } + + // MARK: - Routing helpers + + /// The routing twin of the legacy `v2ResolveWorkspace(params:tabManager:)`, + /// reading the selectors the coordinator already resolved. + private func resolveWorkspace( + routing: ControlRoutingSelectors, + tabManager: TabManager + ) -> Workspace? { + if let wsId = routing.workspaceID { + return tabManager.tabs.first(where: { $0.id == wsId }) + } + if let surfaceId = routing.surfaceID { + return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) + } + if let paneId = routing.paneID, let located = v2LocatePane(paneId) { + guard located.tabManager === tabManager else { return nil } + return located.workspace + } + guard let wsId = tabManager.selectedTabId else { return nil } + return tabManager.tabs.first(where: { $0.id == wsId }) + } + + // MARK: - list + + func controlPaneList(routing: ControlRoutingSelectors) -> ControlPaneListSnapshot? { + guard let tabManager = resolveTabManager(routing: routing), + let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return nil + } + + let focusedPaneId = ws.bonsplitController.focusedPaneId + let snapshot = ws.bonsplitController.layoutSnapshot() + let geometryByPaneId = Dictionary( + snapshot.panes.map { ($0.paneId, $0.frame) }, + uniquingKeysWith: { first, _ in first } + ) + + let panes: [ControlPaneSummary] = ws.bonsplitController.allPaneIds.map { 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) } + + let pixelFrame: ControlPanePixelFrame? = geometryByPaneId[paneId.id.uuidString].map { frame in + ControlPanePixelFrame(x: frame.x, y: frame.y, width: frame.width, height: frame.height) + } + + var gridSize: ControlPaneGridSize? + if let panelUUID = selectedSurfaceUUID, + let panel = ws.panels[panelUUID] as? TerminalPanel, + panel.surface.hasLiveSurface, + let ghosttySurface = panel.surface.surface { + let size = ghostty_surface_size(ghosttySurface) + if size.columns > 0 && size.rows > 0 { + gridSize = ControlPaneGridSize( + columns: Int(size.columns), + rows: Int(size.rows), + cellWidthPx: Int(size.cell_width_px), + cellHeightPx: Int(size.cell_height_px) + ) + } + } + + return ControlPaneSummary( + paneID: paneId.id, + isFocused: paneId == focusedPaneId, + surfaceIDs: surfaceUUIDs, + selectedSurfaceID: selectedSurfaceUUID, + pixelFrame: pixelFrame, + gridSize: gridSize + ) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + return ControlPaneListSnapshot( + workspaceID: ws.id, + windowID: windowId, + panes: panes, + containerWidth: snapshot.containerFrame.width, + containerHeight: snapshot.containerFrame.height + ) + } + + // MARK: - focus + + func controlPaneFocus( + routing: ControlRoutingSelectors, + paneID: UUID + ) -> ControlPaneFocusResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + guard let paneId = ws.bonsplitController.allPaneIds.first(where: { $0.id == paneID }) else { + return .paneNotFound(paneID) + } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } + ws.bonsplitController.focusPane(paneId) + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .focused(windowID: windowId, workspaceID: ws.id, paneID: paneId.id) + } + + // MARK: - surfaces + + func controlPaneSurfaces( + routing: ControlRoutingSelectors, + paneID: UUID? + ) -> ControlPaneSurfacesSnapshot? { + guard let tabManager = resolveTabManager(routing: routing), + let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return nil + } + + let paneId: PaneID? = { + if let paneID { + return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneID }) + } + return ws.bonsplitController.focusedPaneId + }() + guard let paneId else { return nil } + + let selectedTab = ws.bonsplitController.selectedTab(inPane: paneId) + let tabs = ws.bonsplitController.tabs(inPane: paneId) + + let surfaces: [ControlPaneSurfaceSummary] = tabs.map { tab in + let panelId = ws.panelIdFromSurfaceId(tab.id) + let panel = panelId.flatMap { ws.panels[$0] } + return ControlPaneSurfaceSummary( + surfaceID: panelId, + title: tab.title, + typeRawValue: panel?.panelType.rawValue, + isSelected: tab.id == selectedTab?.id + ) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + return ControlPaneSurfacesSnapshot( + workspaceID: ws.id, + paneID: paneId.id, + windowID: windowId, + surfaces: surfaces + ) + } + + // MARK: - create + + func controlPaneCreate( + routing: ControlRoutingSelectors, + inputs: ControlPaneCreateInputs + ) -> ControlPaneCreateResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let directionRaw = inputs.directionRaw, + let direction = parseSplitDirection(directionRaw) else { + return .invalidDirection + } + + let panelType: PanelType = inputs.typeRaw.flatMap { self.panelType(forRawToken: $0) } ?? .terminal + if panelType == .agentSession { + return .agentSessionRejected(typeRawValue: panelType.rawValue) + } + let url = inputs.urlRaw.flatMap { URL(string: $0) } + if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { + return browserDisabledCreateResolution(rawURL: inputs.urlRaw, url: url, tabManager: tabManager) + } + + let orientation = direction.orientation + let insertFirst = direction.insertFirst + + var initialDividerPosition: Double? + if inputs.hasInitialDividerPosition { + guard let rawPosition = inputs.initialDividerPositionRaw, rawPosition.isFinite else { + return .invalidDividerPosition + } + initialDividerPosition = min(max(rawPosition, 0.1), 0.9) + } + + guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + guard let sourcePanelId = inputs.requestedSourceSurfaceID ?? ws.focusedPanelId, + ws.panels[sourcePanelId] != nil else { + return .noSourceSurface + } + + let newPanelId: UUID? + let focus = v2FocusAllowed(requested: inputs.requestedFocus) + if panelType == .browser { + newPanelId = ws.newBrowserSplit( + from: sourcePanelId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: focus, + creationPolicy: .automationPreload, + initialDividerPosition: initialDividerPosition.map { CGFloat($0) } + )?.id + } else { + newPanelId = ws.newTerminalSplit( + from: sourcePanelId, + orientation: orientation, + insertFirst: insertFirst, + focus: focus, + workingDirectory: inputs.workingDirectory, + initialCommand: inputs.initialCommand, + tmuxStartCommand: inputs.tmuxStartCommand, + startupEnvironment: inputs.startupEnvironment, + initialDividerPosition: initialDividerPosition.map { CGFloat($0) } + )?.id + } + + guard let newPanelId else { + return .createFailed + } + let paneUUID = ws.paneId(forPanelId: newPanelId)?.id + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .created( + windowID: windowId, + workspaceID: ws.id, + paneID: paneUUID, + surfaceID: newPanelId, + typeRawValue: panelType.rawValue + ) + } + + /// The byte-faithful twin of `v2PanelType`, mapping a raw token to a + /// `PanelType` (used only by the create path; the coordinator passes the raw + /// string so Bonsplit/PanelType stay app-side). + private func panelType(forRawToken raw: String) -> PanelType? { + switch v2NormalizedToken(raw) { + case "terminal": + return .terminal + case "browser": + return .browser + case "markdown": + return .markdown + case "filepreview": + return .filePreview + case "rightsidebartool": + return .rightSidebarTool + case "agentsession": + return .agentSession + default: + return nil + } + } + + /// The byte-faithful twin of `v2BrowserDisabledExternalOpenResult`, mapped + /// onto ``ControlPaneCreateResolution``. + private func browserDisabledCreateResolution( + rawURL: String?, + url: URL?, + tabManager: TabManager? + ) -> ControlPaneCreateResolution { + if let rawURL, url == nil { + return .browserDisabledInvalidURL(rawURL: rawURL) + } + guard let url else { + return .browserDisabledNoURL + } + guard NSWorkspace.shared.open(url) else { + return .browserDisabledExternalOpenFailed(url: url.absoluteString) + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .browserDisabledOpenedExternally(windowID: windowId, url: url.absoluteString) + } + + // MARK: - resize + + func controlPaneResize( + routing: ControlRoutingSelectors, + inputs: ControlPaneResizeInputs + ) -> ControlPaneResizeResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + + let paneUUID = inputs.paneID ?? ws.bonsplitController.focusedPaneId?.id + guard let paneUUID else { + return .noFocusedPane + } + guard ws.bonsplitController.allPaneIds.contains(where: { $0.id == paneUUID }) else { + return .paneNotFound(paneUUID) + } + + let tree = ws.bonsplitController.treeSnapshot() + var candidates: [V2PaneResizeCandidate] = [] + let trace = v2PaneResizeCollectCandidates( + node: tree, + targetPaneId: paneUUID.uuidString, + candidates: &candidates + ) + guard trace.containsTarget else { + return .paneNotFoundInTree(paneUUID) + } + + if let absoluteAxis = inputs.absoluteAxis, + let targetPixels = inputs.targetPixels, + let absoluteResize = v2SetAbsolutePaneSize( + workspace: ws, + paneUUID: paneUUID, + axis: absoluteAxis, + targetPixels: CGFloat(targetPixels) + ) { + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .absoluteResized( + windowID: windowId, + workspaceID: ws.id, + paneID: paneUUID, + splitID: absoluteResize.splitId, + absoluteAxis: absoluteAxis, + targetPixels: targetPixels, + oldDividerPosition: Double(absoluteResize.oldPosition), + newDividerPosition: Double(absoluteResize.newPosition) + ) + } else if inputs.absoluteAxis != nil || inputs.targetPixels != nil { + return .noAbsoluteSplitAncestor(paneID: paneUUID, absoluteAxis: inputs.absoluteAxis) + } + + guard let direction = inputs.direction.flatMap(V2PaneResizeDirection.init(rawValue:)) else { + // Unreachable: the coordinator pre-validates the relative path. + return .noAdjacentBorder(paneID: paneUUID, direction: inputs.direction ?? "") + } + + let orientationMatches = candidates.filter { $0.orientation == direction.splitOrientation } + guard !orientationMatches.isEmpty else { + return .noOrientationSplitAncestor( + paneID: paneUUID, + orientation: direction.splitOrientation, + direction: direction.rawValue + ) + } + + guard let candidate = orientationMatches.first(where: { + $0.paneInFirstChild == direction.requiresPaneInFirstChild + }) else { + return .noAdjacentBorder(paneID: paneUUID, direction: direction.rawValue) + } + + let delta = CGFloat(inputs.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 { + return .setDividerFailed(splitID: candidate.splitId) + } + + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .relativeResized( + windowID: windowId, + workspaceID: ws.id, + paneID: paneUUID, + splitID: candidate.splitId, + direction: direction.rawValue, + amount: inputs.amount, + oldDividerPosition: Double(candidate.dividerPosition), + newDividerPosition: Double(clamped) + ) + } + + // MARK: - swap + + func controlPaneSwap( + sourcePaneID: UUID, + targetPaneID: UUID, + requestedFocus: Bool + ) -> ControlPaneSwapResolution { + let focus = v2FocusAllowed(requested: requestedFocus) + guard let located = v2LocatePane(sourcePaneID) else { + return .sourcePaneNotFound(sourcePaneID) + } + guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { + $0.id == targetPaneID + }) else { + return .targetPaneNotFound(targetPaneID) + } + 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 { + return .bothPanesNeedSurface + } + + // 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 { + return .sourcePlaceholderFailed + } + } + if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 { + targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id + if targetPlaceholder == nil { + return .targetPlaceholderFailed + } + } + + guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else { + return .moveSourceFailed + } + guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else { + return .moveTargetFailed + } + + if let sourcePlaceholder { + _ = workspace.closePanel(sourcePlaceholder, force: true) + } + if let targetPlaceholder { + _ = workspace.closePanel(targetPlaceholder, force: true) + } + + if focus { + workspace.bonsplitController.focusPane(targetPane) + } + return .swapped( + windowID: located.windowId, + workspaceID: workspace.id, + sourcePaneID: sourcePane.id, + targetPaneID: targetPane.id, + sourceSurfaceID: sourceSurfaceId, + targetSurfaceID: targetSurfaceId + ) + } + + // MARK: - break + + func controlPaneBreak( + routing: ControlRoutingSelectors, + paneID: UUID?, + surfaceID: UUID?, + requestedFocus: Bool + ) -> ControlPaneBreakResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + let focus = v2FocusAllowed(requested: requestedFocus) + guard let sourceWorkspace = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + + let sourcePane: PaneID? = { + if let paneID { + return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == paneID }) + } + return sourceWorkspace.bonsplitController.focusedPaneId + }() + + let resolvedSurfaceId: UUID? = { + if let surfaceID { return surfaceID } + if let sourcePane, + let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) { + return sourceWorkspace.panelIdFromSurfaceId(selected.id) + } + return sourceWorkspace.focusedPanelId + }() + guard let surfaceId = resolvedSurfaceId else { + return .noSourceSurface + } + guard sourceWorkspace.panels[surfaceId] != nil else { + return .surfaceNotFound(surfaceId) + } + let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId) + let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId) + + guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else { + return .detachFailed + } + + guard let destinationWorkspace = tabManager.addWorkspace( + fromDetachedSurface: detached, + select: focus + ) else { + if let sourcePaneForRollback { + _ = sourceWorkspace.attachDetachedSurface( + detached, + inPane: sourcePaneForRollback, + atIndex: sourceIndex, + focus: true + ) + } + return .createWorkspaceFailed + } + guard let destinationPaneId = destinationWorkspace.paneId(forPanelId: surfaceId)?.id else { + return .destinationPaneUnresolved(workspaceID: destinationWorkspace.id, surfaceID: surfaceId) + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .broken( + windowID: windowId, + workspaceID: destinationWorkspace.id, + paneID: destinationPaneId, + surfaceID: surfaceId + ) + } + + // MARK: - join + + func controlPaneJoin( + targetPaneID: UUID, + surfaceID: UUID?, + sourcePaneID: UUID?, + hasFocusParam: Bool, + focus: Bool + ) -> ControlPaneJoinResolution { + var resolvedSurfaceId = surfaceID + if resolvedSurfaceId == nil, let sourcePaneID { + guard let sourceLocated = v2LocatePane(sourcePaneID), + let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId), + let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else { + return .sourceSurfaceUnresolved(sourcePaneID: sourcePaneID) + } + resolvedSurfaceId = selectedSurface + } + guard let surfaceId = resolvedSurfaceId else { + return .missingSurface + } + + var moveParams: [String: Any] = [ + "surface_id": surfaceId.uuidString, + "pane_id": targetPaneID.uuidString, + ] + if hasFocusParam { + moveParams["focus"] = focus + } + return .moved(v2SurfaceMoveControlResult(params: moveParams)) + } + + /// Runs the legacy `v2SurfaceMove` and bridges its Foundation-shaped + /// `V2CallResult` to the typed `ControlCallResult` (the exact pattern + /// `bridgeMobileResult` uses), so `pane.join` forwards the surface-move + /// outcome byte-faithfully. `v2SurfaceMove` is currently `private`; the + /// integrator must relax it to at least `internal` (it lives in + /// `TerminalController.swift`, which this extension cannot reach while + /// `private`). + private func v2SurfaceMoveControlResult(params: [String: Any]) -> ControlCallResult { + switch v2SurfaceMove(params: params) { + case let .ok(payload): + return .ok(JSONValue(foundationObject: payload) ?? .object([:])) + case let .err(code, message, data): + return .err( + code: code, + message: message, + data: data.flatMap { JSONValue(foundationObject: $0) } + ) + } + } + + // MARK: - last + + func controlPaneLast(routing: ControlRoutingSelectors) -> ControlPaneLastResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + guard let focused = ws.bonsplitController.focusedPaneId else { + return .noFocusedPane + } + guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else { + return .noAlternatePane + } + + ws.bonsplitController.focusPane(target) + let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target) + .flatMap { ws.panelIdFromSurfaceId($0.id) } + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .focused( + windowID: windowId, + workspaceID: ws.id, + paneID: target.id, + selectedSurfaceID: selectedSurfaceId + ) + } +} diff --git a/Sources/TerminalController+ControlWorkspaceGroupContext.swift b/Sources/TerminalController+ControlWorkspaceGroupContext.swift new file mode 100644 index 000000000000..bf6a13f8c3b0 --- /dev/null +++ b/Sources/TerminalController+ControlWorkspaceGroupContext.swift @@ -0,0 +1,356 @@ +import CmuxControlSocket +import Foundation + +/// The workspace-group-domain witnesses for the stage-3c +/// ``ControlCommandCoordinator``: the byte-faithful bodies of the former +/// `v2WorkspaceGroup*` dispatchers, minus the per-read `v2MainSync` hop (the +/// coordinator already runs on the main actor inside the socket-command policy +/// scope, so each hop would re-apply the identical thread-local focus-allowance +/// stack — a no-op). TabManager resolution goes through the shared +/// `resolveTabManager(routing:)`; app structs are converted to the package's +/// Sendable snapshots. +extension TerminalController: ControlWorkspaceGroupContext { + func controlWorkspaceGroupStrings() -> ControlWorkspaceGroupStrings { + ControlWorkspaceGroupStrings( + allChildrenAreAnchors: String( + localized: "workspaceGroup.error.allChildrenAreAnchors", + defaultValue: "All requested children are ineligible because they are already group anchors; ungroup them first" + ), + workspaceIsOtherGroupAnchor: String( + localized: "workspaceGroup.error.workspaceIsOtherGroupAnchor", + defaultValue: "Workspace is the anchor of another group; ungroup it first" + ) + ) + } + + /// Builds the Sendable snapshot of one group (the legacy + /// `v2WorkspaceGroupPayload` data, minus the ref minting the coordinator now + /// owns). + private func controlWorkspaceGroupSnapshot( + _ group: WorkspaceGroup, + tabManager: TabManager + ) -> ControlWorkspaceGroupSnapshot { + let memberIds = tabManager.tabs.compactMap { $0.groupId == group.id ? $0.id : nil } + return ControlWorkspaceGroupSnapshot( + id: group.id, + name: group.name, + isCollapsed: group.isCollapsed, + isPinned: group.isPinned, + anchorWorkspaceID: group.anchorWorkspaceId, + customColor: group.customColor, + iconSymbol: group.iconSymbol, + memberWorkspaceIDs: memberIds + ) + } + + func controlWorkspaceGroupList( + routing: ControlRoutingSelectors + ) -> ControlWorkspaceGroupListResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + let groups = tabManager.workspaceGroups.map { + controlWorkspaceGroupSnapshot($0, tabManager: tabManager) + } + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved(windowID: windowId, groups: groups) + } + + func controlCreateWorkspaceGroup( + routing: ControlRoutingSelectors, + name: String, + cwd: String?, + childWorkspaceIDs: [UUID], + childrenExplicit: Bool + ) -> ControlWorkspaceGroupCreateResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + + // Default behavior when children were absent: group the active sidebar + // selection, or fall back to the caller workspace_id, or the focused + // workspace. (An explicit empty array still creates an anchor-only group.) + let parsedChildIds: [UUID] + if childrenExplicit { + parsedChildIds = childWorkspaceIDs + } else { + let selected = tabManager.sidebarSelectedWorkspaceIds + if !selected.isEmpty { + parsedChildIds = tabManager.tabs.compactMap { selected.contains($0.id) ? $0.id : nil } + } else if let callerId = routing.workspaceID, + tabManager.tabs.contains(where: { $0.id == callerId }) { + parsedChildIds = [callerId] + } else if let selectedId = tabManager.selectedTabId { + parsedChildIds = [selectedId] + } else { + parsedChildIds = [] + } + } + + // A syntactically valid UUID can still reference a workspace that doesn't + // exist in this TabManager. Surface those instead of silently dropping + // them into an anchor-only group. + let knownTabIds = Set(tabManager.tabs.map(\.id)) + let missing: [String] = parsedChildIds.compactMap { id in + knownTabIds.contains(id) ? nil : id.uuidString + } + if !missing.isEmpty { + return .childWorkspaceNotFound(missing) + } + let childIds = parsedChildIds + + // When the caller explicitly listed children, refuse to create an + // anchor-only group if every one of them was already an anchor of + // another group. + if childrenExplicit, !parsedChildIds.isEmpty { + let existingAnchorIds = Set(tabManager.workspaceGroups.map(\.anchorWorkspaceId)) + let ineligible: [String] = parsedChildIds.compactMap { id -> String? in + guard tabManager.tabs.contains(where: { $0.id == id }) else { return nil } + if existingAnchorIds.contains(id) { + return id.uuidString + } + return nil + } + if ineligible.count == parsedChildIds.count { + return .allChildrenAreAnchors(ineligible) + } + } + + // workspace.group.create is NOT a focus-intent method; do not change the + // user's active workspace. + let createdGroupId = tabManager.createWorkspaceGroup( + name: name, + childWorkspaceIds: childIds, + anchorWorkingDirectory: cwd, + selectAnchor: false, + collapseSidebarSelection: false + ) + guard let gid = createdGroupId, + let group = tabManager.workspaceGroups.first(where: { $0.id == gid }) else { + return .notCreated + } + return .created(controlWorkspaceGroupSnapshot(group, tabManager: tabManager)) + } + + func controlUngroupWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + let found = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + if found { + tabManager.ungroupWorkspaceGroup(groupId: groupID) + } + return found + } + + func controlDeleteWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID + ) -> Int? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + guard tabManager.workspaceGroups.contains(where: { $0.id == groupID }) else { return -1 } + return tabManager.deleteWorkspaceGroup(groupId: groupID) + } + + func controlRenameWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + name: String + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + let ok = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + if ok { tabManager.renameWorkspaceGroup(groupId: groupID, name: name) } + return ok + } + + func controlSetWorkspaceGroupCollapsed( + routing: ControlRoutingSelectors, + groupID: UUID, + isCollapsed: Bool + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + let ok = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + if ok { tabManager.setWorkspaceGroupCollapsed(groupId: groupID, isCollapsed: isCollapsed) } + return ok + } + + func controlSetWorkspaceGroupPinned( + routing: ControlRoutingSelectors, + groupID: UUID, + isPinned: Bool + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + let ok = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + if ok { tabManager.setWorkspaceGroupPinned(groupId: groupID, isPinned: isPinned) } + return ok + } + + func controlAddWorkspaceToGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + workspaceID: UUID + ) -> ControlWorkspaceGroupAddResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + let hasGroup = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + guard let tab = tabManager.tabs.first(where: { $0.id == workspaceID }), hasGroup else { + return .notFound + } + // addWorkspaceToGroup silently no-ops for anchors of other groups. + // Confirm membership actually changed before reporting success. + tabManager.addWorkspaceToGroup(workspaceId: workspaceID, groupId: groupID) + if tab.groupId == groupID { + return .added + } + if tabManager.workspaceGroups.contains(where: { $0.id != groupID && $0.anchorWorkspaceId == workspaceID }) { + return .workspaceIsOtherGroupAnchor + } + return .notFound + } + + func controlRemoveWorkspaceFromGroup( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + if let tab = tabManager.tabs.first(where: { $0.id == workspaceID }), tab.groupId != nil { + tabManager.removeWorkspaceFromGroup(workspaceId: workspaceID) + return true + } + return false + } + + func controlSetWorkspaceGroupAnchor( + routing: ControlRoutingSelectors, + groupID: UUID, + workspaceID: UUID + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + let hasGroup = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + let hasWs = tabManager.tabs.contains(where: { $0.id == workspaceID && $0.groupId == groupID }) + if hasGroup && hasWs { + tabManager.setWorkspaceGroupAnchor(groupId: groupID, workspaceId: workspaceID) + return true + } + return false + } + + func controlCreateWorkspaceInGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + placementRaw: String? + ) -> ControlWorkspaceGroupNewWorkspaceResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + // Placement resolution: explicit `placement` param wins, then the group's + // per-cwd `newWorkspacePlacement` from cmux.json, then the global default. + let explicitPlacement = WorkspaceGroupNewPlacement(rawString: placementRaw) + if let raw = placementRaw, + !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + explicitPlacement == nil { + return .invalidPlacement(raw) + } + guard let group = tabManager.workspaceGroups.first(where: { $0.id == groupID }) else { + return .notFound + } + let anchorCwd = tabManager.tabs.first(where: { $0.id == group.anchorWorkspaceId })?.currentDirectory + let configStore = AppDelegate.shared?.mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.cmuxConfigStore + let configured = configStore?.resolveWorkspaceGroupConfig(forCwd: anchorCwd)?.newWorkspacePlacement + let placement = explicitPlacement + ?? configured + ?? WorkspaceGroupNewWorkspacePlacementSettings.resolved() + guard let newWs = tabManager.createWorkspaceInGroup( + groupId: groupID, + placement: placement, + select: false + ) else { + return .notFound + } + return .created(workspaceID: newWs.id) + } + + func controlSetWorkspaceGroupColor( + routing: ControlRoutingSelectors, + groupID: UUID, + hex: String? + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + let ok = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + if ok { tabManager.setWorkspaceGroupColor(groupId: groupID, hex: hex) } + return ok + } + + func controlSetWorkspaceGroupIcon( + routing: ControlRoutingSelectors, + groupID: UUID, + symbol: String? + ) -> (found: Bool, storedSymbol: String?)? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + let found = tabManager.workspaceGroups.contains(where: { $0.id == groupID }) + var storedIconSymbol: String? + if found { + storedIconSymbol = tabManager.setWorkspaceGroupIcon(groupId: groupID, symbol: symbol) + } + return (found, storedIconSymbol) + } + + func controlMoveWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID, + toIndex: Int?, + beforeGroupID: UUID?, + afterGroupID: UUID? + ) -> Bool? { + guard let tabManager = resolveTabManager(routing: routing) else { return nil } + guard let current = tabManager.workspaceGroups.firstIndex(where: { $0.id == groupID }) else { + return false + } + // moveWorkspaceGroup interprets toIndex as the FINAL position the group + // should occupy. before/after refer to a peer's CURRENT index, so when + // the source comes before the peer in the original order, removing the + // source shifts the peer left by one, and the translated final position + // must shift with it. + let target: Int? = { + if let toIndex { + return toIndex + } + if let beforeId = beforeGroupID, + let beforeIndex = tabManager.workspaceGroups.firstIndex(where: { $0.id == beforeId }) { + return current < beforeIndex ? beforeIndex - 1 : beforeIndex + } + if let afterId = afterGroupID, + let afterIndex = tabManager.workspaceGroups.firstIndex(where: { $0.id == afterId }) { + return current < afterIndex ? afterIndex : afterIndex + 1 + } + return nil + }() + guard let target else { return false } + tabManager.moveWorkspaceGroup(groupId: groupID, toIndex: target) + return true + } + + func controlFocusWorkspaceGroup( + routing: ControlRoutingSelectors, + groupID: UUID + ) -> ControlWorkspaceGroupFocusResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let group = tabManager.workspaceGroups.first(where: { $0.id == groupID }), + let anchor = tabManager.tabs.first(where: { $0.id == group.anchorWorkspaceId }) else { + return .notFound + } + if let windowId = AppDelegate.shared?.windowId(for: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + // Route through selectWorkspace so the explicit-resume notification + // dismissal and other selection side effects fire, matching + // workspace.select and the sidebar header click path. + tabManager.selectWorkspace(anchor) + return .focused(anchorWorkspaceID: anchor.id) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 7f42fea0d0ac..0586ae8ca051 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1903,22 +1903,8 @@ class TerminalController { return v2Ok(id: id, result: ["pong": true]) case "system.capabilities": return v2Ok(id: id, result: v2Capabilities()) - case "mobile.host.status": - return v2Result(id: id, self.v2MobileHostStatus(params: params)) - case "mobile.workspace.list": - return v2Result(id: id, self.v2MobileWorkspaceList(params: params)) - case "mobile.terminal.create", "terminal.create": - return v2Result(id: id, self.v2MobileTerminalCreate(params: params)) - case "mobile.terminal.input", "terminal.input": - return v2Result(id: id, self.v2MobileTerminalInput(params: params)) - case "mobile.terminal.replay", "terminal.replay": - return v2Result(id: id, self.v2MobileTerminalReplay(params: params)) - case "mobile.terminal.viewport", "terminal.viewport": - return v2Result(id: id, self.v2MobileTerminalViewport(params: params)) - case "mobile.terminal.scroll", "terminal.scroll": - return v2Result(id: id, self.v2MobileTerminalScroll(params: params)) - case "mobile.terminal.mouse", "terminal.mouse": - return v2Result(id: id, self.v2MobileTerminalMouse(params: params)) + // mobile.host.status/mobile.workspace.list/mobile.terminal.* (+terminal.* aliases) + // handled by ControlCommandCoordinator (bodies stay; shared with mobileHostHandleRPC). case "system.identify": return v2Ok(id: id, result: v2Identify(params: params)) @@ -1964,40 +1950,7 @@ class TerminalController { return v2Result(id: id, self.v2WorkspacePromptSubmit(params: params)) case "workspace.rename": return v2Result(id: id, self.v2WorkspaceRename(params: params)) - case "workspace.group.list": - return v2Result(id: id, self.v2WorkspaceGroupList(params: params)) - case "workspace.group.create": - return v2Result(id: id, self.v2WorkspaceGroupCreate(params: params)) - case "workspace.group.ungroup": - return v2Result(id: id, self.v2WorkspaceGroupUngroup(params: params)) - case "workspace.group.delete": - return v2Result(id: id, self.v2WorkspaceGroupDelete(params: params)) - case "workspace.group.rename": - return v2Result(id: id, self.v2WorkspaceGroupRename(params: params)) - case "workspace.group.collapse": - return v2Result(id: id, self.v2WorkspaceGroupSetCollapsed(params: params, isCollapsed: true)) - case "workspace.group.expand": - return v2Result(id: id, self.v2WorkspaceGroupSetCollapsed(params: params, isCollapsed: false)) - case "workspace.group.pin": - return v2Result(id: id, self.v2WorkspaceGroupSetPinned(params: params, isPinned: true)) - case "workspace.group.unpin": - return v2Result(id: id, self.v2WorkspaceGroupSetPinned(params: params, isPinned: false)) - case "workspace.group.add": - return v2Result(id: id, self.v2WorkspaceGroupAdd(params: params)) - case "workspace.group.remove": - return v2Result(id: id, self.v2WorkspaceGroupRemove(params: params)) - case "workspace.group.set_anchor": - return v2Result(id: id, self.v2WorkspaceGroupSetAnchor(params: params)) - case "workspace.group.new_workspace": - return v2Result(id: id, self.v2WorkspaceGroupNewWorkspace(params: params)) - case "workspace.group.set_color": - return v2Result(id: id, self.v2WorkspaceGroupSetColor(params: params)) - case "workspace.group.set_icon": - return v2Result(id: id, self.v2WorkspaceGroupSetIcon(params: params)) - case "workspace.group.move": - return v2Result(id: id, self.v2WorkspaceGroupMove(params: params)) - case "workspace.group.focus": - return v2Result(id: id, self.v2WorkspaceGroupFocus(params: params)) + // workspace.group.* handled by ControlCommandCoordinator. case "workspace.action": return v2Result(id: id, self.v2WorkspaceAction(params: params)) case "extension.sidebar.snapshot": @@ -2093,24 +2046,7 @@ class TerminalController { 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)) + // pane.* handled by ControlCommandCoordinator. // Notifications: all but notification.create_for_caller handled by // ControlCommandCoordinator (create_for_caller keeps its app-side resolver). @@ -4796,528 +4732,6 @@ class TerminalController { ]) } - // MARK: - Workspace Groups (v2) - - @MainActor - private func v2WorkspaceGroupPayload(_ group: WorkspaceGroup, tabManager: TabManager) -> [String: Any] { - let memberIds = tabManager.tabs.compactMap { $0.groupId == group.id ? $0.id : nil } - return [ - "id": group.id.uuidString, - "ref": v2Ref(kind: .workspaceGroup, uuid: group.id), - "name": group.name, - "is_collapsed": group.isCollapsed, - "is_pinned": group.isPinned, - "anchor_workspace_id": group.anchorWorkspaceId.uuidString, - "anchor_workspace_ref": v2Ref(kind: .workspace, uuid: group.anchorWorkspaceId), - "custom_color": v2OrNull(group.customColor), - "icon_symbol": v2OrNull(group.iconSymbol), - "member_workspace_ids": memberIds.map { $0.uuidString }, - "member_workspace_refs": memberIds.map { v2Ref(kind: .workspace, uuid: $0) }, - "member_count": memberIds.count - ] - } - - private func v2WorkspaceGroupList(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - var groups: [[String: Any]] = [] - v2MainSync { - groups = tabManager.workspaceGroups.map { v2WorkspaceGroupPayload($0, tabManager: tabManager) } - } - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "groups": groups - ]) - } - - private func v2WorkspaceGroupCreate(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - let name = (params["name"] as? String) ?? "" - let cwd = params["cwd"] as? String - // child_workspace_ids accepts raw UUID strings AND v2 handle refs - // (workspace:1, ws:1, etc.) so callers can use whatever they got back - // from workspace.list / workspace-group list. - // - // Default behavior when the param is absent (e.g. `cmux workspace-group - // create --name foo` from a cmux terminal): group the active sidebar - // selection, or fall back to the caller workspace_id, or the focused - // workspace. An empty array (explicit `--from ""`) still creates an - // anchor-only group. - let rawChildren: [String] - let childrenExplicit: Bool - if let provided = params["child_workspace_ids"] as? [String] { - rawChildren = provided - childrenExplicit = true - } else if params["child_workspace_ids"] != nil, - !(params["child_workspace_ids"] is NSNull) { - // Reject malformed shapes (single string, mixed array, etc.) so - // a typo in a script doesn't silently apply the create to the - // current sidebar selection. Empty/absent → fall through. - return .err( - code: "invalid_params", - message: "child_workspace_ids must be an array of workspace handles", - data: ["child_workspace_ids": String(describing: params["child_workspace_ids"] ?? "")] - ) - } else { - let fallbackIds: [UUID] = v2MainSync { - let selected = tabManager.sidebarSelectedWorkspaceIds - if !selected.isEmpty { - return tabManager.tabs.compactMap { selected.contains($0.id) ? $0.id : nil } - } - if let callerId = v2UUID(params, "workspace_id"), - tabManager.tabs.contains(where: { $0.id == callerId }) { - return [callerId] - } - if let selectedId = tabManager.selectedTabId { - return [selectedId] - } - return [] - } - rawChildren = fallbackIds.map { $0.uuidString } - childrenExplicit = false - } - var unresolved: [String] = [] - let parsedChildIds: [UUID] = rawChildren.compactMap { raw -> UUID? in - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if let uuid = v2UUIDAny(trimmed) { - return uuid - } - unresolved.append(trimmed) - return nil - } - if !unresolved.isEmpty { - return .err( - code: "invalid_params", - message: "Unresolved child workspace handles: \(unresolved.joined(separator: ", "))", - data: ["unresolved": unresolved] - ) - } - // A syntactically valid UUID can still reference a workspace that - // doesn't exist in this TabManager (typo, stale snapshot from a - // closed window). Surface those explicitly instead of letting - // createWorkspaceGroup silently drop them and produce an - // anchor-only group. - let knownTabIds: Set = v2MainSync { Set(tabManager.tabs.map(\.id)) } - let missing: [String] = parsedChildIds.compactMap { id in - knownTabIds.contains(id) ? nil : id.uuidString - } - if !missing.isEmpty { - return .err( - code: "not_found", - message: "Child workspace not found in target window: \(missing.joined(separator: ", "))", - data: ["unknown_workspace_ids": missing] - ) - } - let childIds = parsedChildIds - // When the caller explicitly listed children, refuse to create an - // anchor-only group if every one of them was already an anchor of - // another group. The keyboard-shortcut path - // already enforces this; the socket/CLI path used to return OK with - // a fresh empty group, hiding the real failure. - if childrenExplicit, !parsedChildIds.isEmpty { - let ineligible: [String] = v2MainSync { - let existingAnchorIds = Set(tabManager.workspaceGroups.map(\.anchorWorkspaceId)) - return parsedChildIds.compactMap { id -> String? in - guard tabManager.tabs.contains(where: { $0.id == id }) else { return nil } - if existingAnchorIds.contains(id) { - return id.uuidString - } - return nil - } - } - if ineligible.count == parsedChildIds.count { - return .err( - code: "invalid_state", - message: String( - localized: "workspaceGroup.error.allChildrenAreAnchors", - defaultValue: "All requested children are ineligible because they are already group anchors; ungroup them first" - ), - data: ["ineligible_workspace_ids": ineligible] - ) - } - } - // workspace.group.create is NOT a focus-intent method. The select - // option used to be honored here, but the socket focus policy says - // non-focus commands must not change the user's active workspace. - // Callers that want to focus the new anchor should call - // workspace.group.focus afterward (which IS focus-intent). - var createdGroupId: UUID? - v2MainSync { - createdGroupId = tabManager.createWorkspaceGroup( - name: name, - childWorkspaceIds: childIds, - anchorWorkingDirectory: cwd, - selectAnchor: false, - collapseSidebarSelection: false - ) - } - guard let gid = createdGroupId, - let group = v2MainSync({ tabManager.workspaceGroups.first(where: { $0.id == gid }) }) else { - return .err(code: "not_created", message: "Group was not created", data: nil) - } - return .ok([ - "group": v2MainSync { v2WorkspaceGroupPayload(group, tabManager: tabManager) } - ]) - } - - private func v2WorkspaceGroupUngroup(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - var found = false - v2MainSync { - found = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - if found { - tabManager.ungroupWorkspaceGroup(groupId: gid) - } - } - guard found else { - return .err(code: "not_found", message: "Group not found", data: [ - "group_id": gid.uuidString - ]) - } - return .ok(["group_id": gid.uuidString]) - } - - private func v2WorkspaceGroupDelete(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - var found = false - var closedCount = 0 - v2MainSync { - found = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - if found { - closedCount = tabManager.deleteWorkspaceGroup(groupId: gid) - } - } - guard found else { - return .err(code: "not_found", message: "Group not found", data: [ - "group_id": gid.uuidString - ]) - } - return .ok([ - "group_id": gid.uuidString, - "closed_workspace_count": closedCount, - ]) - } - - private func v2WorkspaceGroupRename(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id"), - let name = v2String(params, "name") else { - return .err(code: "invalid_params", message: "Missing group_id or name", data: nil) - } - var ok = false - v2MainSync { - ok = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - if ok { tabManager.renameWorkspaceGroup(groupId: gid, name: name) } - } - return ok - ? .ok(["group_id": gid.uuidString, "name": name]) - : .err(code: "not_found", message: "Group not found", data: ["group_id": gid.uuidString]) - } - - private func v2WorkspaceGroupSetCollapsed(params: [String: Any], isCollapsed: Bool) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - var ok = false - v2MainSync { - ok = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - if ok { tabManager.setWorkspaceGroupCollapsed(groupId: gid, isCollapsed: isCollapsed) } - } - return ok - ? .ok(["group_id": gid.uuidString, "is_collapsed": isCollapsed]) - : .err(code: "not_found", message: "Group not found", data: ["group_id": gid.uuidString]) - } - - private func v2WorkspaceGroupSetPinned(params: [String: Any], isPinned: Bool) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - var ok = false - v2MainSync { - ok = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - if ok { tabManager.setWorkspaceGroupPinned(groupId: gid, isPinned: isPinned) } - } - return ok - ? .ok(["group_id": gid.uuidString, "is_pinned": isPinned]) - : .err(code: "not_found", message: "Group not found", data: ["group_id": gid.uuidString]) - } - - private func v2WorkspaceGroupAdd(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id"), - let wsId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing group_id or workspace_id", data: nil) - } - var failureCode = "not_found" - var failureMessage = "Group or workspace not found" - var ok = false - v2MainSync { - let hasGroup = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - guard let tab = tabManager.tabs.first(where: { $0.id == wsId }), hasGroup else { - return - } - // addWorkspaceToGroup silently no-ops for anchors of other - // groups. Confirm membership actually changed before reporting - // success so scripts don't get OK on a no-op. - tabManager.addWorkspaceToGroup(workspaceId: wsId, groupId: gid) - if tab.groupId == gid { - ok = true - } else { - if tabManager.workspaceGroups.contains(where: { $0.id != gid && $0.anchorWorkspaceId == wsId }) { - failureCode = "invalid_state" - failureMessage = String( - localized: "workspaceGroup.error.workspaceIsOtherGroupAnchor", - defaultValue: "Workspace is the anchor of another group; ungroup it first" - ) - } - } - } - return ok - ? .ok(["group_id": gid.uuidString, "workspace_id": wsId.uuidString]) - : .err(code: failureCode, message: failureMessage, data: [ - "group_id": gid.uuidString, - "workspace_id": wsId.uuidString - ]) - } - - private func v2WorkspaceGroupRemove(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 ok = false - v2MainSync { - if let tab = tabManager.tabs.first(where: { $0.id == wsId }), tab.groupId != nil { - tabManager.removeWorkspaceFromGroup(workspaceId: wsId) - ok = true - } - } - return ok - ? .ok(["workspace_id": wsId.uuidString]) - : .err(code: "not_found", message: "Workspace not in a group", data: ["workspace_id": wsId.uuidString]) - } - - private func v2WorkspaceGroupSetAnchor(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id"), - let wsId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing group_id or workspace_id", data: nil) - } - var ok = false - v2MainSync { - let hasGroup = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - let hasWs = tabManager.tabs.contains(where: { $0.id == wsId && $0.groupId == gid }) - if hasGroup && hasWs { - tabManager.setWorkspaceGroupAnchor(groupId: gid, workspaceId: wsId) - ok = true - } - } - return ok - ? .ok(["group_id": gid.uuidString, "anchor_workspace_id": wsId.uuidString]) - : .err(code: "not_found", message: "Group not found or workspace not a member", data: [ - "group_id": gid.uuidString, - "workspace_id": wsId.uuidString - ]) - } - - private func v2WorkspaceGroupNewWorkspace(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - // workspace.group.new_workspace is NOT a focus-intent method. The - // socket focus policy says non-focus commands must not change the - // user's active workspace; callers that want to focus the new - // workspace should call workspace.select / workspace.group.focus - // afterward. - // - // Placement resolution: explicit `placement` param wins, then the - // group's per-cwd `newWorkspacePlacement` from cmux.json, then the - // global default. The CLI exposes this as - // `cmux workspace-group new-workspace --placement `. - let placementRaw = v2String(params, "placement") - let explicitPlacement = WorkspaceGroupNewPlacement(rawString: placementRaw) - if let raw = placementRaw, - !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - explicitPlacement == nil { - return .err( - code: "invalid_params", - message: "placement must be one of: afterCurrent, top, end", - data: ["placement": raw] - ) - } - var createdId: UUID? - v2MainSync { - guard let group = tabManager.workspaceGroups.first(where: { $0.id == gid }) else { return } - let anchorCwd = tabManager.tabs.first(where: { $0.id == group.anchorWorkspaceId })?.currentDirectory - let configStore = AppDelegate.shared?.mainWindowContexts.values.first(where: { $0.tabManager === tabManager })?.cmuxConfigStore - let configured = configStore?.resolveWorkspaceGroupConfig(forCwd: anchorCwd)?.newWorkspacePlacement - let placement = explicitPlacement - ?? configured - ?? WorkspaceGroupNewWorkspacePlacementSettings.resolved() - if let newWs = tabManager.createWorkspaceInGroup( - groupId: gid, - placement: placement, - select: false - ) { - createdId = newWs.id - } - } - guard let createdId else { - return .err(code: "not_found", message: "Group not found", data: ["group_id": gid.uuidString]) - } - return .ok([ - "group_id": gid.uuidString, - "workspace_id": createdId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: createdId) - ]) - } - - private func v2WorkspaceGroupSetColor(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - // Accept "hex": null to clear the override, or omit it entirely. - let hex: String? = (params["hex"] as? String).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - let normalized: String? = (hex?.isEmpty == false) ? hex : nil - var ok = false - v2MainSync { - ok = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - if ok { tabManager.setWorkspaceGroupColor(groupId: gid, hex: normalized) } - } - return ok - ? .ok(["group_id": gid.uuidString, "custom_color": v2OrNull(normalized)]) - : .err(code: "not_found", message: "Group not found", data: ["group_id": gid.uuidString]) - } - - private func v2WorkspaceGroupSetIcon(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - let symbol: String? = (params["symbol"] as? String).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - let normalized: String? = (symbol?.isEmpty == false) ? symbol : nil - var ok = false - var storedIconSymbol: String? - v2MainSync { - ok = tabManager.workspaceGroups.contains(where: { $0.id == gid }) - if ok { - storedIconSymbol = tabManager.setWorkspaceGroupIcon(groupId: gid, symbol: normalized) - } - } - return ok - ? .ok(["group_id": gid.uuidString, "icon_symbol": v2OrNull(storedIconSymbol)]) - : .err(code: "not_found", message: "Group not found", data: ["group_id": gid.uuidString]) - } - - private func v2WorkspaceGroupMove(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - // Resolve target via explicit absolute index OR relative position to - // another group via `before_group_id` / `after_group_id`. - var ok = false - v2MainSync { - guard let current = tabManager.workspaceGroups.firstIndex(where: { $0.id == gid }) else { return } - // moveWorkspaceGroup interprets toIndex as the FINAL position the - // group should occupy. before/after refer to a peer's CURRENT - // index, so when the source comes before the peer in the original - // order, removing the source shifts the peer left by one, and the - // translated final position must shift with it. - let target: Int? = { - if let toIndex = v2Int(params, "to_index") { - return toIndex - } - if let beforeId = v2UUID(params, "before_group_id"), - let beforeIndex = tabManager.workspaceGroups.firstIndex(where: { $0.id == beforeId }) { - return current < beforeIndex ? beforeIndex - 1 : beforeIndex - } - if let afterId = v2UUID(params, "after_group_id"), - let afterIndex = tabManager.workspaceGroups.firstIndex(where: { $0.id == afterId }) { - return current < afterIndex ? afterIndex : afterIndex + 1 - } - return nil - }() - guard let target else { return } - tabManager.moveWorkspaceGroup(groupId: gid, toIndex: target) - ok = true - } - return ok - ? .ok(["group_id": gid.uuidString]) - : .err(code: "invalid_params", message: "Missing or unresolvable target position", data: ["group_id": gid.uuidString]) - } - - private func v2WorkspaceGroupFocus(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - guard let gid = v2UUID(params, "group_id") else { - return .err(code: "invalid_params", message: "Missing or invalid group_id", data: nil) - } - var anchorId: UUID? - v2MainSync { - guard let group = tabManager.workspaceGroups.first(where: { $0.id == gid }), - let anchor = tabManager.tabs.first(where: { $0.id == group.anchorWorkspaceId }) else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - // Route through selectWorkspace so the explicit-resume - // notification dismissal and other selection side effects fire, - // matching workspace.select and the sidebar header click path. - tabManager.selectWorkspace(anchor) - anchorId = anchor.id - } - guard let anchorId else { - return .err(code: "not_found", message: "Group or anchor not found", data: ["group_id": gid.uuidString]) - } - return .ok([ - "group_id": gid.uuidString, - "anchor_workspace_id": anchorId.uuidString, - "anchor_workspace_ref": v2Ref(kind: .workspace, uuid: anchorId) - ]) - } - private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "TabManager not available", data: nil) @@ -8151,7 +7565,9 @@ class TerminalController { return result } - private func v2SurfaceMove(params: [String: Any]) -> V2CallResult { + // `internal` (not `private`): the Pane domain's app conformance forwards + // `pane.join` to this body. The Surface domain extraction will relocate it. + 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) } @@ -9258,648 +8674,6 @@ class TerminalController { 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 snapshot = ws.bonsplitController.layoutSnapshot() - let geometryByPaneId = Dictionary( - snapshot.panes.map { ($0.paneId, $0.frame) }, - uniquingKeysWith: { first, _ in first } - ) - - 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) } - - var dict: [String: Any] = [ - "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 - ] - - if let frame = geometryByPaneId[paneId.id.uuidString] { - dict["pixel_frame"] = [ - "x": frame.x, "y": frame.y, - "width": frame.width, "height": frame.height - ] - } - - // Get terminal grid size from the selected surface - if let panelUUID = selectedSurfaceUUID, - let panel = ws.panels[panelUUID] as? TerminalPanel, - panel.surface.hasLiveSurface, - let ghosttySurface = panel.surface.surface { - let size = ghostty_surface_size(ghosttySurface) - if size.columns > 0 && size.rows > 0 { - dict["columns"] = Int(size.columns) - dict["rows"] = Int(size.rows) - dict["cell_width_px"] = Int(size.cell_width_px) - dict["cell_height_px"] = Int(size.cell_height_px) - } - } - - return dict - } - - let windowId = v2ResolveWindowId(tabManager: tabManager) - var payloadDict: [String: Any] = [ - "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) - ] - payloadDict["container_frame"] = [ - "width": snapshot.containerFrame.width, - "height": snapshot.containerFrame.height - ] - payload = payloadDict - } - - 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 - } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(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 - if panelType == .agentSession { - return .err( - code: "invalid_params", - message: "agent-session is only supported by surface.create", - data: ["type": panelType.rawValue] - ) - } - let urlStr = v2String(params, "url") - let url = urlStr.flatMap { URL(string: $0) } - let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") - let initialCommand = v2OptionalTrimmedRawString(params, "initial_command") - let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") - let startupEnvironment = v2TrimmedStringMap(params, keys: ["startup_environment", "initial_env"]) - if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { - return v2BrowserDisabledExternalOpenResult(rawURL: urlStr, url: url, tabManager: tabManager) - } - - let orientation = direction.orientation - let insertFirst = direction.insertFirst - let parsedInitialDivider = v2InitialDividerPosition(params) - if let error = parsedInitialDivider.error { - return error - } - let initialDividerPosition = parsedInitialDivider.value - - 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) - let requestedPanelId = v2String(params, "surface_id").flatMap(UUID.init(uuidString:)) - guard let sourcePanelId = requestedPanelId ?? ws.focusedPanelId, - ws.panels[sourcePanelId] != nil else { - result = .err(code: "not_found", message: "No source surface to split", data: nil) - return - } - - let newPanelId: UUID? - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - if panelType == .browser { - newPanelId = ws.newBrowserSplit( - from: sourcePanelId, - orientation: orientation, - insertFirst: insertFirst, - url: url, - focus: focus, - creationPolicy: .automationPreload, - initialDividerPosition: initialDividerPosition.map { CGFloat($0) } - )?.id - } else { - newPanelId = ws.newTerminalSplit( - from: sourcePanelId, - orientation: orientation, - insertFirst: insertFirst, - focus: focus, - workingDirectory: workingDirectory, - initialCommand: initialCommand, - tmuxStartCommand: tmuxStartCommand, - startupEnvironment: startupEnvironment, - initialDividerPosition: initialDividerPosition.map { CGFloat($0) } - )?.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 func v2PaneResize(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - - let absoluteAxis = v2String(params, "absolute_axis")?.lowercased() - let targetPixels = v2Double(params, "target_pixels") - let directionRaw = (v2String(params, "direction") ?? "").lowercased() - let amount = v2Int(params, "amount") ?? 1 - let direction = V2PaneResizeDirection(rawValue: directionRaw) - let hasAbsoluteIntent = params.keys.contains("absolute_axis") || params.keys.contains("target_pixels") - if hasAbsoluteIntent { - guard let absoluteAxis, - absoluteAxis == "horizontal" || absoluteAxis == "vertical" else { - return .err(code: "invalid_params", message: "absolute_axis must be 'horizontal' or 'vertical'", data: nil) - } - guard let targetPixels, targetPixels > 0 else { - return .err(code: "invalid_params", message: "target_pixels must be > 0", data: nil) - } - } else { - guard direction != nil, 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 - } - - if let absoluteAxis, - let targetPixels, - let absoluteResize = v2SetAbsolutePaneSize( - workspace: ws, - paneUUID: paneUUID, - axis: absoluteAxis, - targetPixels: CGFloat(targetPixels) - ) { - 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": absoluteResize.splitId.uuidString, - "absolute_axis": absoluteAxis, - "target_pixels": targetPixels, - "old_divider_position": absoluteResize.oldPosition, - "new_divider_position": absoluteResize.newPosition - ]) - return - } else if absoluteAxis != nil || targetPixels != nil { - result = .err( - code: "invalid_state", - message: "No split ancestor for absolute pane resize", - data: ["pane_id": paneUUID.uuidString, "absolute_axis": v2OrNull(absoluteAxis)] - ) - return - } - - guard let direction else { - result = .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil) - 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") ?? false) - - 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") ?? false) - - 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 - } - - guard let destinationWorkspace = tabManager.addWorkspace( - fromDetachedSurface: detached, - select: focus - ) else { - if let sourcePaneForRollback { - _ = sourceWorkspace.attachDetachedSurface( - detached, - inPane: sourcePaneForRollback, - atIndex: sourceIndex, - focus: true - ) - } - result = .err(code: "internal_error", message: "Failed to create workspace for detached surface", data: nil) - return - } - guard let destinationPaneId = destinationWorkspace.paneId(forPanelId: surfaceId)?.id else { - result = .err( - code: "internal_error", - message: "Failed to resolve destination pane for detached surface", - data: [ - "workspace_id": destinationWorkspace.id.uuidString, - "surface_id": surfaceId.uuidString - ] - ) - 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": destinationPaneId.uuidString, - "pane_ref": v2Ref(kind: .pane, uuid: destinationPaneId), - "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 - } - private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { let workspaceId = v2UUID(params, "workspace_id") let windowId = v2UUID(params, "window_id") @@ -20602,7 +19376,7 @@ class TerminalController { } } - private func v2MobileHostStatus( + func v2MobileHostStatus( params: [String: Any], includePrivateMetadata: Bool = true ) -> V2CallResult { @@ -20743,7 +19517,7 @@ class TerminalController { } } - private func v2MobileWorkspaceList( + func v2MobileWorkspaceList( params: [String: Any], tabManager resolvedTabManager: TabManager? = nil, createdWorkspaceID: String? = nil, @@ -21029,7 +19803,7 @@ class TerminalController { } } - private func v2MobileTerminalCreate(params: [String: Any]) -> V2CallResult { + func v2MobileTerminalCreate(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) } @@ -21058,7 +19832,7 @@ class TerminalController { ) } - private func v2MobileTerminalReplay(params: [String: Any]) -> V2CallResult { + func v2MobileTerminalReplay(params: [String: Any]) -> V2CallResult { if let error = mobileWorkspaceIDValidationError(params: params) { return error } @@ -21123,7 +19897,7 @@ class TerminalController { /// render to match. This is the iOS/macOS half of the tmux-style shared /// resize: the smallest attached viewport wins and every device shows the /// same cols×rows with a clear border around the live area. - private func v2MobileTerminalViewport(params: [String: Any]) -> V2CallResult { + func v2MobileTerminalViewport(params: [String: Any]) -> V2CallResult { if let error = mobileWorkspaceIDValidationError(params: params) { return error } @@ -21165,7 +19939,7 @@ class TerminalController { /// in the alt screen). The producer already exports the live `vp_top`, so /// the resulting viewport mirrors back to the phone; nudge an emit since a /// pure scroll with no PTY output may not fire a render/tick on its own. - private func v2MobileTerminalScroll(params: [String: Any]) -> V2CallResult { + func v2MobileTerminalScroll(params: [String: Any]) -> V2CallResult { if let error = mobileWorkspaceIDValidationError(params: params) { return error } @@ -21190,7 +19964,7 @@ class TerminalController { ]) } - private func v2MobileTerminalMouse(params: [String: Any]) -> V2CallResult { + func v2MobileTerminalMouse(params: [String: Any]) -> V2CallResult { if let error = mobileWorkspaceIDValidationError(params: params) { return error } @@ -21212,7 +19986,7 @@ class TerminalController { ]) } - private func v2MobileTerminalInput(params: [String: Any]) -> V2CallResult { + func v2MobileTerminalInput(params: [String: Any]) -> V2CallResult { guard let text = v2RawString(params, "text"), !text.isEmpty else { return .err(code: "invalid_params", message: "Missing text", data: nil) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index a70a1de19445..7769034901c3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -609,7 +609,10 @@ C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; }; C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */; }; C0DE00000000000000000C44 /* TerminalController+ControlFeedContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */; }; + C0DE00000000000000000C52 /* TerminalController+ControlMobileHostContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */; }; C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */; }; + C0DE00000000000000000C56 /* TerminalController+ControlPaneContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */; }; + C0DE00000000000000000C54 /* TerminalController+ControlWorkspaceGroupContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; C0DE00000000000000000C32 /* TerminalControllerControlCommandContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */; }; @@ -1348,7 +1351,10 @@ C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = ""; }; C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlAppFocusContext.swift"; sourceTree = ""; }; C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlFeedContext.swift"; sourceTree = ""; }; + C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlMobileHostContext.swift"; sourceTree = ""; }; C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlNotificationContext.swift"; sourceTree = ""; }; + C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlPaneContext.swift"; sourceTree = ""; }; + C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlWorkspaceGroupContext.swift"; sourceTree = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; C0DE00000000000000000C31 /* TerminalControllerControlCommandContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalControllerControlCommandContext.swift; sourceTree = ""; }; @@ -1859,6 +1865,9 @@ C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */, C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */, C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */, + C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */, + C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */, + C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */, C7A50B000000000000000001 /* TerminalControllerV2ParamParsingSupport.swift */, C7A505000000000000000001 /* TerminalControllerTopSupport.swift */, C7A501000000000000000001 /* CmuxTopSnapshot.swift */, @@ -3028,7 +3037,10 @@ C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */, C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */, C0DE00000000000000000C44 /* TerminalController+ControlFeedContext.swift in Sources */, + C0DE00000000000000000C52 /* TerminalController+ControlMobileHostContext.swift in Sources */, C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */, + C0DE00000000000000000C56 /* TerminalController+ControlPaneContext.swift in Sources */, + C0DE00000000000000000C54 /* TerminalController+ControlWorkspaceGroupContext.swift in Sources */, D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, C0DE00000000000000000C32 /* TerminalControllerControlCommandContext.swift in Sources */, From 4b8118fb5c9c7fc4d12c1ba1677f26a88e7cb413 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 14:46:44 -0700 Subject: [PATCH 05/21] stage 3c: fix int/double param helpers to match legacy NSNumber coercion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression found by the no-regression code review of the moved domains: the ported int() did Int(value) on a JSON double, which TRAPS (crashes) on overflow/ NaN — reachable via pane.resize amount or workspace.group.move to_index with e.g. 1e30 — whereas legacy v2Int went through (params[key] as? NSNumber).intValue, which clamps. Also int()/double() didn't coerce a JSON boolean to a number the way the legacy as? NSNumber path did. Both now route doubles/bools through NSNumber.intValue/.doubleValue, matching v2Int/v2Double exactly (truncate-toward-zero, clamp out-of-range, bool->1/0). 5 regression tests cover truncation, overflow/NaN no-trap, and bool coercion. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ControlCommandCoordinator+Params.swift | 17 +++++- ...ControlCommandCoordinatorParamsTests.swift | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorParamsTests.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift index 11912ec5d382..9a21b2c6fc12 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Params.swift @@ -103,13 +103,20 @@ extension ControlCommandCoordinator { } } - /// `v2Int`: a JSON int, a truncated double, or a parsable string. + /// `v2Int`: a JSON int, a number, or a parsable string. Doubles and bools go + /// through `NSNumber.intValue` to match the legacy `params[key] as? NSNumber` + /// path EXACTLY — that truncates toward zero and clamps out-of-range/NaN + /// rather than trapping (a plain `Int(Double)` traps on overflow/NaN, e.g. a + /// caller passing `1e30` to `pane.resize`/`workspace.group.move`). func int(_ params: [String: JSONValue], _ key: String) -> Int? { switch params[key] { case .int(let value): return Int(value) case .double(let value): - return Int(value) + return NSNumber(value: value).intValue + case .bool(let value): + // Legacy `as? NSNumber` caught a JSON boolean and `.intValue` → 1/0. + return NSNumber(value: value).intValue case .string(let value): return Int(value) default: @@ -117,13 +124,17 @@ extension ControlCommandCoordinator { } } - /// `v2Double`: a JSON double, int, or parsable string. + /// `v2Double`: a JSON double, int, bool, or parsable string. Numbers/bools go + /// through `NSNumber.doubleValue`, matching the legacy `as? NSNumber` path + /// (which coerced a JSON boolean to `1.0`/`0.0`). func double(_ params: [String: JSONValue], _ key: String) -> Double? { switch params[key] { case .double(let value): return value case .int(let value): return Double(value) + case .bool(let value): + return NSNumber(value: value).doubleValue case .string(let value): return Double(value) default: diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorParamsTests.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorParamsTests.swift new file mode 100644 index 000000000000..8a1b859421cb --- /dev/null +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorParamsTests.swift @@ -0,0 +1,55 @@ +import Foundation +import Testing +@testable import CmuxControlSocket + +/// Regression coverage for the shared numeric param helpers, which must match the +/// legacy `v2Int`/`v2Double` (`as? NSNumber` → `intValue`/`doubleValue`) coercion +/// EXACTLY — including not trapping on out-of-range/NaN doubles (a plain +/// `Int(Double)` traps; the legacy `NSNumber.intValue` clamps). +@MainActor +@Suite("ControlCommandCoordinator numeric params") +struct ControlCommandCoordinatorParamsTests { + private func coordinator() -> ControlCommandCoordinator { + ControlCommandCoordinator() + } + + @Test func intTruncatesTowardZeroLikeNSNumber() { + let c = coordinator() + #expect(c.int(["x": .double(2.9)], "x") == 2) + #expect(c.int(["x": .double(-2.9)], "x") == -2) + #expect(c.int(["x": .int(7)], "x") == 7) + #expect(c.int(["x": .string("42")], "x") == 42) + #expect(c.int(["x": .string("2.5")], "x") == nil) + } + + @Test func intDoesNotTrapOnOverflowOrNaN() { + let c = coordinator() + // Plain Int(1e30)/Int(.nan) would trap; must match NSNumber.intValue. + #expect(c.int(["x": .double(1e30)], "x") == NSNumber(value: 1e30).intValue) + #expect(c.int(["x": .double(-1e30)], "x") == NSNumber(value: -1e30).intValue) + #expect(c.int(["x": .double(.nan)], "x") == NSNumber(value: Double.nan).intValue) + #expect(c.int(["x": .double(.infinity)], "x") == NSNumber(value: Double.infinity).intValue) + } + + @Test func intCoercesJSONBooleanLikeLegacy() { + let c = coordinator() + #expect(c.int(["x": .bool(true)], "x") == 1) + #expect(c.int(["x": .bool(false)], "x") == 0) + } + + @Test func doubleCoercesJSONBooleanLikeLegacy() { + let c = coordinator() + #expect(c.double(["x": .bool(true)], "x") == 1.0) + #expect(c.double(["x": .bool(false)], "x") == 0.0) + #expect(c.double(["x": .int(3)], "x") == 3.0) + #expect(c.double(["x": .double(2.5)], "x") == 2.5) + #expect(c.double(["x": .string("1.25")], "x") == 1.25) + } + + @Test func numericHelpersReturnNilForAbsentOrNonNumeric() { + let c = coordinator() + #expect(c.int([:], "x") == nil) + #expect(c.int(["x": .null], "x") == nil) + #expect(c.double(["x": .array([])], "x") == nil) + } +} From 1905b3829b0240da202413a5d94b99d5571a2341 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 15:57:25 -0700 Subject: [PATCH 06/21] stage 3c: integrate Workspace + Surface domains (full lifts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace (21 methods incl. remote.*) and Surface (25 methods + debug.terminals) move into ControlCommandCoordinator behind ControlWorkspaceContext/ ControlSurfaceContext. ~2640 lines deleted from TerminalController.swift (20296 -> ~17650). Worker-lane workspace.remote.pty_* stay app-side. Two shared bodies the drafting agents wrongly flagged for deletion were RESTORED (internal/private): v2WorkspaceCreate(params:tabManager:) is still driven by the mobile data-plane v2MobileWorkspaceCreate; workspaceCloseProtectedMessage() by the v1 close path. surface.move + debug.terminals forward to the still-shared v2SurfaceMove/v2DebugTerminals (relaxed internal), like pane.join. Relaxed to internal for the conformances: tabManager, socketFastPathState, orderedPanels, readTerminalTextRawSnapshot. Live socket sweep on ctl3c1 confirms faithful payloads + errors across both domains (workspace list/current/create/rename/select/next/close, surface list/ current/health/send_text+read_text round-trip/resume.get, error shapes). 133 package tests green. KNOWN FOLLOW-UPS: workspace.create logic is duplicated (conformance reimplements + restored shared body) — dedupe by forwarding; the 2 Workspace files >500 lines (budget entries added) should be split; adversarial code-review verification of these 2 domains still pending (8 prior domains verified clean). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 4 +- .../Coordinator/ControlCommandContext.swift | 4 +- .../ControlCommandCoordinator+Surface.swift | 497 ++ .../ControlCommandCoordinator+Surface2.swift | 352 ++ .../ControlCommandCoordinator+Surface3.swift | 305 ++ .../ControlCommandCoordinator+Workspace.swift | 871 +++ .../ControlCommandCoordinator.swift | 2 + ...ControlSurfaceBrowserDisabledOutcome.swift | 23 + ...ControlSurfaceClearHistoryResolution.swift | 27 + .../ControlSurfaceCloseResolution.swift | 27 + .../Coordinator/ControlSurfaceContext.swift | 341 ++ .../ControlSurfaceCreateInputs.swift | 61 + .../ControlSurfaceCreateResolution.swift | 37 + .../ControlSurfaceCurrentSnapshot.swift | 43 + .../ControlSurfaceFocusResolution.swift | 20 + .../ControlSurfaceHealthEntry.swift | 35 + .../ControlSurfaceHealthSnapshot.swift | 32 + .../ControlSurfaceInputStrings.swift | 34 + .../ControlSurfaceListSnapshot.swift | 31 + .../ControlSurfacePortsKickResolution.swift | 24 + .../ControlSurfaceReadTextResolution.swift | 34 + .../ControlSurfaceRefreshResolution.swift | 15 + .../ControlSurfaceReorderInputs.swift | 28 + .../ControlSurfaceReorderResolution.swift | 22 + ...rolSurfaceReportShellStateResolution.swift | 18 + .../ControlSurfaceReportTTYResolution.swift | 24 + .../ControlSurfaceRespawnInputs.swift | 49 + .../ControlSurfaceRespawnResolution.swift | 38 + .../ControlSurfaceRespawnStrings.swift | 46 + .../ControlSurfaceResumeBinding.swift | 76 + .../ControlSurfaceResumeResolution.swift | 25 + .../ControlSurfaceResumeSetInputs.swift | 59 + .../ControlSurfaceResumeSnapshot.swift | 48 + .../ControlSurfaceSendResolution.swift | 39 + .../ControlSurfaceSplitInputs.swift | 61 + .../ControlSurfaceSplitResolution.swift | 37 + .../Coordinator/ControlSurfaceSummary.swift | 89 + ...ControlSurfaceTriggerFlashResolution.swift | 22 + .../ControlWorkspaceCloseResolution.swift | 20 + .../Coordinator/ControlWorkspaceContext.swift | 283 + .../ControlWorkspaceCreateInputs.swift | 67 + .../ControlWorkspaceCreateResolution.swift | 23 + .../ControlWorkspaceCurrentResolution.swift | 21 + .../ControlWorkspaceEqualizeResolution.swift | 15 + .../ControlWorkspaceListResolution.swift | 22 + ...ntrolWorkspaceMoveToWindowResolution.swift | 16 + ...ControlWorkspaceNavigationResolution.swift | 20 + ...ntrolWorkspacePromptSubmitResolution.swift | 23 + ...orkspaceRemotePTYAttachEndResolution.swift | 24 + .../ControlWorkspaceRemoteResolution.swift | 28 + ...ceRemoteTerminalSessionEndResolution.swift | 13 + ...ontrolWorkspaceReorderManyResolution.swift | 21 + .../ControlWorkspaceReorderPlanItem.swift | 27 + .../ControlWorkspaceReorderResolution.swift | 12 + .../ControlWorkspaceRoutedResolution.swift | 17 + .../Coordinator/ControlWorkspaceStrings.swift | 49 + .../Coordinator/ControlWorkspaceSummary.swift | 74 + .../ControlCommandContextTestStubs.swift | 261 + ...ControlCommandCoordinatorWindowTests.swift | 3 +- ...inalController+ControlSurfaceContext.swift | 200 + ...nalController+ControlSurfaceContext2.swift | 378 ++ ...nalController+ControlSurfaceContext3.swift | 352 ++ ...nalController+ControlSurfaceContext4.swift | 412 ++ ...alController+ControlWorkspaceContext.swift | 805 +++ Sources/TerminalController.swift | 4872 ++++------------- cmux.xcodeproj/project.pbxproj | 20 + 66 files changed, 7831 insertions(+), 3747 deletions(-) create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface2.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface3.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceBrowserDisabledOutcome.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceClearHistoryResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCloseResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCurrentSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceFocusResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthEntry.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceInputStrings.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceListSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfacePortsKickResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReadTextResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRefreshResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportShellStateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportTTYResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnStrings.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeBinding.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSetInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSendResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSummary.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceTriggerFlashResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCloseResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCurrentResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceEqualizeResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceListResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceMoveToWindowResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceNavigationResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspacePromptSubmitResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemotePTYAttachEndResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteTerminalSessionEndResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderManyResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderPlanItem.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRoutedResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceStrings.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceSummary.swift create mode 100644 Sources/TerminalController+ControlSurfaceContext.swift create mode 100644 Sources/TerminalController+ControlSurfaceContext2.swift create mode 100644 Sources/TerminalController+ControlSurfaceContext3.swift create mode 100644 Sources/TerminalController+ControlSurfaceContext4.swift create mode 100644 Sources/TerminalController+ControlWorkspaceContext.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index c1831e5f6020..d5fb7774e322 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,7 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 32655 CLI/cmux.swift -20296 Sources/TerminalController.swift +17580 Sources/TerminalController.swift 19820 Sources/Workspace.swift 19209 Sources/ContentView.swift 18011 Sources/AppDelegate.swift @@ -188,3 +188,5 @@ 502 Sources/Settings/ConfigSource.swift 611 Sources/TerminalController+ControlPaneContext.swift 533 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift +871 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift +805 Sources/TerminalController+ControlWorkspaceContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift index c3802ac4a0b4..3a44991e1281 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandContext.swift @@ -20,5 +20,7 @@ public protocol ControlCommandContext: ControlNotificationContext, ControlWorkspaceGroupContext, ControlPaneContext, - ControlMobileHostContext + ControlMobileHostContext, + ControlWorkspaceContext, + ControlSurfaceContext {} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface.swift new file mode 100644 index 000000000000..d7c0f4a851f6 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface.swift @@ -0,0 +1,497 @@ +internal import Foundation + +/// The surface domain (`surface.*` plus `debug.terminals`), lifted byte-faithfully +/// from the former `TerminalController.v2Surface*` / `v2DebugTerminals` bodies. +/// Each payload is built directly as a ``JSONValue``; the encoded wire bytes match. +/// The coordinator owns param parsing and ref minting; the app-coupled work runs +/// behind the ``ControlSurfaceContext`` seam. +/// +/// This file carries the dispatch plus the read/lifecycle methods; the remaining +/// methods live in `+Surface2.swift` / `+Surface3.swift` (500-line budget). +extension ControlCommandCoordinator { + + /// Runs one decoded request if it belongs to the surface domain, returning the + /// typed result; returns `nil` otherwise so the caller can fall through. The + /// integrator calls this from the core `handle`. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a surface method. + func handleSurface(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "surface.list": + return surfaceList(request.params) + case "surface.current": + return surfaceCurrent(request.params) + case "surface.focus": + return surfaceFocus(request.params) + case "surface.split": + return surfaceSplit(request.params) + case "surface.respawn": + return surfaceRespawn(request.params) + case "surface.create": + return surfaceCreate(request.params) + case "surface.close": + return surfaceClose(request.params) + case "surface.move": + return surfaceMove(request.params) + case "surface.reorder": + return surfaceReorder(request.params) + case "surface.refresh": + return surfaceRefresh(request.params) + case "surface.health": + return surfaceHealth(request.params) + case "surface.resume.set": + return surfaceResumeSet(request.params) + case "surface.resume.get": + return surfaceResumeGet(request.params) + case "surface.resume.clear": + return surfaceResumeClear(request.params) + case "surface.send_text": + return surfaceSendText(request.params) + case "surface.send_key": + return surfaceSendKey(request.params) + case "surface.report_tty": + return surfaceReportTTY(request.params) + case "surface.report_shell_state": + return surfaceReportShellState(request.params) + case "surface.ports_kick": + return surfacePortsKick(request.params) + case "surface.clear_history": + return surfaceClearHistory(request.params) + case "surface.read_text": + return surfaceReadText(request.params) + case "surface.trigger_flash": + return surfaceTriggerFlash(request.params) + case "debug.terminals": + return debugTerminals(request.params) + default: + return nil + } + } + + /// The shared "cmux window is not available" message (legacy + /// `Self.v2WindowUnavailableMessage`). + static let surfaceWindowUnavailableMessage = + "cmux window is not available. Reopen the window and try again." + + // MARK: - list + + /// `surface.list` — the resolved workspace's surfaces. + func surfaceList(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let snapshot = context?.controlSurfaceList(routing: routing) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + + let surfaces: [JSONValue] = snapshot.surfaces.enumerated().map { index, surface in + var item: [String: JSONValue] = [ + "id": .string(surface.surfaceID.uuidString), + "ref": ref(.surface, surface.surfaceID), + "index": .int(Int64(index)), + "type": .string(surface.typeRawValue), + "title": .string(surface.title), + "focused": .bool(surface.isFocused), + "pane_id": orNull(surface.paneID?.uuidString), + "pane_ref": ref(.pane, surface.paneID), + "index_in_pane": surface.indexInPane.map { .int(Int64($0)) } ?? .null, + "selected_in_pane": surface.selectedInPane.map { .bool($0) } ?? .null, + ] + if let dev = surface.developerToolsVisible { + item["developer_tools_visible"] = .bool(dev) + } + if surface.isTerminal { + item["requested_working_directory"] = orNull(surface.requestedWorkingDirectory) + item["initial_command"] = orNull(surface.initialCommand) + item["tmux_start_command"] = orNull(surface.tmuxStartCommand) + item["resume_binding"] = surfaceResumeBindingPayload(surface.resumeBinding) + } + return .object(item) + } + + return .ok(.object([ + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "surfaces": .array(surfaces), + "window_id": orNull(snapshot.windowID?.uuidString), + "window_ref": ref(.window, snapshot.windowID), + ])) + } + + // MARK: - current + + /// `surface.current` — the resolved workspace's current surface. + func surfaceCurrent(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let snapshot = context?.controlSurfaceCurrent(routing: routing) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + return .ok(.object([ + "window_id": orNull(snapshot.windowID?.uuidString), + "window_ref": ref(.window, snapshot.windowID), + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "pane_id": orNull(snapshot.paneID?.uuidString), + "pane_ref": ref(.pane, snapshot.paneID), + "surface_id": orNull(snapshot.surfaceID?.uuidString), + "surface_ref": ref(.surface, snapshot.surfaceID), + "surface_type": orNull(snapshot.surfaceTypeRawValue), + ])) + } + + // MARK: - health + + /// `surface.health` — render health for the resolved workspace's surfaces. + func surfaceHealth(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let snapshot = context?.controlSurfaceHealth(routing: routing) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + let items: [JSONValue] = snapshot.surfaces.enumerated().map { index, entry in + .object([ + "index": .int(Int64(index)), + "id": .string(entry.surfaceID.uuidString), + "ref": ref(.surface, entry.surfaceID), + "type": .string(entry.typeRawValue), + "in_window": entry.inWindow.map { .bool($0) } ?? .null, + ]) + } + return .ok(.object([ + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "surfaces": .array(items), + "window_id": orNull(snapshot.windowID?.uuidString), + "window_ref": ref(.window, snapshot.windowID), + ])) + } + + // MARK: - focus + + /// `surface.focus` — focus a surface in the resolved workspace. + func surfaceFocus(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let resolution = context?.controlSurfaceFocus(routing: routing, surfaceID: surfaceID) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .surfaceNotFound(let id): + return .err( + code: "not_found", + message: "Surface not found", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .focused(let windowID, let workspaceID, let focusedSurfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(focusedSurfaceID.uuidString), + "surface_ref": ref(.surface, focusedSurfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - split + + /// `surface.split` — split a surface into a new pane. + func surfaceSplit(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let directionRaw = string(params, "direction") else { + return .err( + code: "invalid_params", + message: "Missing or invalid direction (left|right|up|down)", + data: nil + ) + } + let parsedDivider = initialDividerPosition(params) + if let error = parsedDivider.error { return error } + + let inputs = ControlSurfaceSplitInputs( + directionRaw: directionRaw, + typeRaw: string(params, "type"), + urlRaw: string(params, "url"), + requestedSourceSurfaceID: uuid(params, "surface_id"), + workingDirectory: optionalTrimmedRawString(params, "working_directory"), + initialCommand: optionalTrimmedRawString(params, "initial_command"), + tmuxStartCommand: optionalTrimmedRawString(params, "tmux_start_command"), + remotePTYSessionID: optionalTrimmedRawString(params, "remote_pty_session_id"), + startupEnvironment: trimmedStringMap(params, keys: ["startup_environment", "initial_env"]), + requestedFocus: bool(params, "focus") ?? false, + initialDividerPosition: parsedDivider.value + ) + + let resolution = context?.controlSurfaceSplit(routing: routing, inputs: inputs) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .agentSessionRejected(let typeRawValue): + return .err( + code: "invalid_params", + message: "agent-session is only supported by surface.create", + data: .object(["type": .string(typeRawValue)]) + ) + case .browserDisabled(let outcome): + return browserDisabledResult(outcome) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .requestedSurfaceNotFound(let id): + return .err( + code: "not_found", + message: "Surface not found", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface", data: nil) + case .createFailed: + return .err(code: "internal_error", message: "Failed to create split", data: nil) + case .created(let windowID, let workspaceID, let paneID, let surfaceID, let typeRawValue): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": orNull(paneID?.uuidString), + "pane_ref": ref(.pane, paneID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "type": orNull(typeRawValue), + ])) + } + } + + // MARK: - respawn + + /// `surface.respawn` — respawn a terminal surface. + func surfaceRespawn(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard let strings = context?.controlSurfaceRespawnStrings() else { + // No seam wired; the focused-branch fallback would also be unavailable. + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let command = optionalTrimmedRawString(params, "command") + ?? optionalTrimmedRawString(params, "initial_command") + ?? "exec ${SHELL:-/bin/zsh} -l" + let tmuxStartCommand = optionalTrimmedRawString(params, "tmux_start_command") ?? command + let workingDirectory = optionalTrimmedRawString(params, "working_directory") + + let hasFocusParam = hasNonNull(params, "focus") + if hasFocusParam, bool(params, "focus") == nil { + return .err(code: "invalid_params", message: strings.invalidFocus, data: nil) + } + + let inputs = ControlSurfaceRespawnInputs( + command: command, + tmuxStartCommand: tmuxStartCommand, + workingDirectory: workingDirectory, + hasSurfaceIDParam: hasNonNull(params, "surface_id"), + requestedSurfaceID: uuid(params, "surface_id"), + hasFocusParam: hasFocusParam, + requestedFocus: bool(params, "focus") ?? false + ) + + let resolution = context?.controlSurfaceRespawn(routing: routing, inputs: inputs) + guard let resolution else { + return .err(code: "internal_error", message: strings.failed, data: nil) + } + switch resolution { + case .surfaceNotFoundForID(let id): + return .err( + code: "not_found", + message: strings.surfaceNotFoundForID, + data: id.map { .object(["surface_id": .string($0.uuidString)]) } + ) + case .tabManagerUnavailable: + return .err(code: "unavailable", message: strings.tabManagerUnavailable, data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: strings.workspaceNotFound, data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: strings.noFocusedSurface, data: nil) + case .surfaceNotTerminal(let id): + return .err( + code: "invalid_params", + message: strings.surfaceNotTerminal, + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .respawnFailed(let id): + return .err( + code: "internal_error", + message: strings.failed, + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .respawned(let windowID, let workspaceID, let surfaceID, let typeRawValue): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "type": .string(typeRawValue), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - create + + /// `surface.create` — create a surface in a pane. + func surfaceCreate(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let inputs = ControlSurfaceCreateInputs( + typeRaw: string(params, "type"), + providerRaw: string(params, "provider_id") ?? string(params, "provider"), + rendererRaw: string(params, "renderer_kind") ?? string(params, "renderer"), + urlRaw: string(params, "url"), + workingDirectory: optionalTrimmedRawString(params, "working_directory"), + initialCommand: optionalTrimmedRawString(params, "initial_command"), + tmuxStartCommand: optionalTrimmedRawString(params, "tmux_start_command"), + remotePTYSessionID: optionalTrimmedRawString(params, "remote_pty_session_id"), + startupEnvironment: trimmedStringMap(params, keys: ["startup_environment", "initial_env"]), + requestedPaneID: uuid(params, "pane_id"), + requestedFocus: bool(params, "focus") ?? false + ) + + let resolution = context?.controlSurfaceCreate(routing: routing, inputs: inputs) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidProvider(let rawValue): + return .err( + code: "invalid_params", + message: "Invalid provider (codex|claude|opencode)", + data: .object(["provider": .string(rawValue)]) + ) + case .invalidRenderer(let rawValue): + return .err( + code: "invalid_params", + message: "Invalid renderer (react|solid)", + data: .object(["renderer": .string(rawValue)]) + ) + case .browserDisabled(let outcome): + return browserDisabledResult(outcome) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .paneNotFound: + return .err(code: "not_found", message: "Pane not found", data: nil) + case .createFailed: + return .err(code: "internal_error", message: "Failed to create surface", data: nil) + case .created(let windowID, let workspaceID, let paneID, let surfaceID, let typeRawValue): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(paneID.uuidString), + "pane_ref": ref(.pane, paneID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "type": .string(typeRawValue), + ])) + } + } + + // MARK: - close + + /// `surface.close` — force-close a surface. + func surfaceClose(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let resolution = context?.controlSurfaceClose(routing: routing, surfaceID: uuid(params, "surface_id")) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface", data: nil) + case .surfaceNotFound(let id): + return .err( + code: "not_found", + message: "Surface not found", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .lastSurface: + return .err(code: "invalid_state", message: "Cannot close the last surface", data: nil) + case .closeFailed(let id): + return .err( + code: "internal_error", + message: "Failed to close surface", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .closed(let windowID, let workspaceID, let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - browser-disabled shared payload + + /// The shared `surface.split` / `surface.create` browser-disabled external-open + /// result (byte-faithful twin of `v2BrowserDisabledExternalOpenResult`). + func browserDisabledResult(_ outcome: ControlSurfaceBrowserDisabledOutcome) -> ControlCallResult { + switch outcome { + case .invalidURL(let rawURL): + return .err(code: "invalid_params", message: "Invalid URL", data: .object(["url": .string(rawURL)])) + case .noURL: + return .err(code: "browser_disabled", message: "cmux browser is disabled", data: nil) + case .externalOpenFailed(let url): + return .err( + code: "external_open_failed", + message: "Failed to open URL externally", + data: .object(["url": .string(url)]) + ) + case .openedExternally(let windowID, let url): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .null, + "workspace_ref": .null, + "pane_id": .null, + "pane_ref": .null, + "surface_id": .null, + "surface_ref": .null, + "created_split": .bool(false), + "opened_externally": .bool(true), + "browser_disabled": .bool(true), + "placement_strategy": .string("external_browser_disabled"), + "url": .string(url), + ])) + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface2.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface2.swift new file mode 100644 index 000000000000..7dc31ff8fe87 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface2.swift @@ -0,0 +1,352 @@ +internal import Foundation + +/// The remaining surface-domain bodies (move/reorder/refresh/clear_history/ +/// trigger_flash/send_text/send_key/read_text/resume.*/report_*/ports_kick and the +/// `debug.terminals` passthrough), split out of +/// `ControlCommandCoordinator+Surface.swift` to keep each file under the 500-line +/// budget. See that file's doc comment for the domain overview. +extension ControlCommandCoordinator { + + // MARK: - move + + /// `surface.move` — move a surface (delegates to the still-app-side + /// surface-move logic; the app bridges the result byte-faithfully). + func surfaceMove(_ params: [String: JSONValue]) -> ControlCallResult { + context?.controlSurfaceMove(params: params) + ?? .err(code: "internal_error", message: "Failed to move surface", data: nil) + } + + // MARK: - reorder + + /// `surface.reorder` — reorder a surface within its pane. + func surfaceReorder(_ params: [String: JSONValue]) -> ControlCallResult { + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let index = int(params, "index") + let beforeSurfaceID = uuid(params, "before_surface_id") + let afterSurfaceID = uuid(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 + ) + } + + let inputs = ControlSurfaceReorderInputs( + index: index, + beforeSurfaceID: beforeSurfaceID, + afterSurfaceID: afterSurfaceID + ) + let resolution = context?.controlSurfaceReorder( + surfaceID: surfaceID, + inputs: inputs, + requestedFocus: bool(params, "focus") ?? false + ) ?? .surfaceNotFound(surfaceID) + switch resolution { + case .surfaceNotFound(let id): + return .err( + code: "not_found", + message: "Surface not found", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .anchorNotInSamePane: + return .err(code: "invalid_params", message: "Anchor surface must be in the same pane", data: nil) + case .reorderFailed: + return .err(code: "internal_error", message: "Failed to reorder surface", data: nil) + case .reordered(let windowID, let workspaceID, let paneID, let surfaceID): + return .ok(.object([ + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(paneID.uuidString), + "pane_ref": ref(.pane, paneID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + ])) + } + } + + // MARK: - refresh + + /// `surface.refresh` — force-refresh every terminal surface. + func surfaceRefresh(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let resolution = context?.controlSurfaceRefresh(routing: routing) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .refreshed(let windowID, let workspaceID, let refreshedCount): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "refreshed": .int(Int64(refreshedCount)), + ])) + } + } + + // MARK: - clear_history + + /// `surface.clear_history` — clear a terminal surface's screen/history. + func surfaceClearHistory(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let resolution = context?.controlSurfaceClearHistory( + routing: routing, + surfaceID: surfaceIDForInput(params) + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .surfaceNotFoundForID: + return .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface", data: nil) + case .surfaceNotTerminal(let id): + return .err( + code: "invalid_params", + message: "Surface is not a terminal", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .bindingActionUnavailable: + return .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil) + case .cleared(let windowID, let workspaceID, let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - trigger_flash + + /// `surface.trigger_flash` — flash a surface's focus indicator. + func surfaceTriggerFlash(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let resolution = context?.controlSurfaceTriggerFlash( + routing: routing, + surfaceID: uuid(params, "surface_id") + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface", data: nil) + case .surfaceNotFound(let id): + return .err( + code: "not_found", + message: "Surface not found", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .flashed(let windowID, let workspaceID, let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - send_text / send_key + + /// `surface.send_text` — inject literal text into a terminal surface. + func surfaceSendText(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + // Legacy `params["text"] as? String` (no trim, no ref/UUID handling). + guard case .string(let text)? = params["text"] else { + return .err(code: "invalid_params", message: "Missing text", data: nil) + } + let resolution = context?.controlSurfaceSendText( + routing: routing, + surfaceID: uuid(params, "surface_id"), + hasSurfaceIDParam: params["surface_id"] != nil, + text: text + ) ?? .tabManagerUnavailable + return surfaceSendResult(resolution) + } + + /// `surface.send_key` — send a named key to a terminal surface. + func surfaceSendKey(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let key = string(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + let resolution = context?.controlSurfaceSendKey( + routing: routing, + surfaceID: uuid(params, "surface_id"), + hasSurfaceIDParam: params["surface_id"] != nil, + key: key + ) ?? .tabManagerUnavailable + return surfaceSendResult(resolution, key: key) + } + + /// Shapes the shared send-text / send-key result, selecting the localized + /// terminal-input error messages from the app-resolved strings. + private func surfaceSendResult( + _ resolution: ControlSurfaceSendResolution, + key: String? = nil + ) -> ControlCallResult { + let strings = context?.controlSurfaceInputStrings() + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .surfaceNotFoundForID: + return .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface", data: nil) + case .surfaceNotTerminal(let id): + return .err( + code: "invalid_params", + message: "Surface is not a terminal", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .unknownKey: + return .err( + code: "invalid_params", + message: "Unknown key", + data: .object(["key": .string(key ?? "")]) + ) + case .inputQueueFull(let id): + return .err( + code: "input_queue_full", + message: strings?.inputQueueFull ?? "", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .surfaceUnavailable(let id): + return .err( + code: "surface_unavailable", + message: strings?.surfaceUnavailable ?? "", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .processExited(let id): + return .err( + code: "process_exited", + message: strings?.processExited ?? "", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .sent(let windowID, let workspaceID, let surfaceID, let queued): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "queued": .bool(queued), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - read_text + + /// `surface.read_text` — read a terminal surface's visible / scrollback text. + func surfaceReadText(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + var includeScrollback = bool(params, "scrollback") ?? false + let lineLimit = int(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 + } + let resolution = context?.controlSurfaceReadText( + routing: routing, + surfaceID: uuid(params, "surface_id"), + hasSurfaceIDParam: params["surface_id"] != nil, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) ?? .internalError(message: "Failed to read terminal text") + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .surfaceNotFoundForID: + return .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface", data: nil) + case .surfaceNotTerminal(let id): + return .err( + code: "invalid_params", + message: "Surface is not a terminal", + data: .object(["surface_id": .string(id.uuidString)]) + ) + case .internalError(let message): + return .err(code: "internal_error", message: message, data: nil) + case .read(let text, let base64, let windowID, let workspaceID, let surfaceID): + return .ok(.object([ + "text": .string(text), + "base64": .string(base64), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - debug.terminals + + /// `debug.terminals` — the global terminal-surface debug table. The payload is + /// dozens of irreducibly app-coupled `NSWindow`/`NSView`/Ghostty-pointer + /// fields, so the app returns it already bridged to a ``JSONValue`` (the + /// documented single-method passthrough exception). + func debugTerminals(_ params: [String: JSONValue]) -> ControlCallResult { + guard let payload = context?.controlDebugTerminals() else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) + } + return .ok(payload) + } + + // MARK: - helpers + + /// The `surface_id` selector for the body methods that resolve the focused + /// surface when no `surface_id` is given (`clear_history`). Matches the legacy + /// `v2UUID(params, "surface_id") ?? focused` precedence by returning the parsed + /// id (or `nil` to defer to the focused surface). + func surfaceIDForInput(_ params: [String: JSONValue]) -> UUID? { + uuid(params, "surface_id") + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface3.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface3.swift new file mode 100644 index 000000000000..61e2549cfd37 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface3.swift @@ -0,0 +1,305 @@ +internal import Foundation + +/// The surface-domain resume (`surface.resume.*`) and reporting +/// (`surface.report_tty` / `report_shell_state` / `ports_kick`) bodies, plus the +/// shared resume-binding payload helper, split out of +/// `ControlCommandCoordinator+Surface.swift` to keep each file under the 500-line +/// budget. See that file's doc comment for the domain overview. +extension ControlCommandCoordinator { + + // MARK: - resume target param validation + + /// The byte-faithful twin of `v2SurfaceResumeTargetValidationError`: an + /// `invalid_params` error when any of `window_id` / `workspace_id` / + /// `surface_id` / `tab_id` is present-but-non-null yet does not resolve. + private func surfaceResumeTargetValidationError( + _ params: [String: JSONValue] + ) -> ControlCallResult? { + for key in ["window_id", "workspace_id", "surface_id", "tab_id"] where hasNonNull(params, key) { + if uuid(params, key) == nil { + return .err(code: "invalid_params", message: "Missing or invalid \(key)", data: nil) + } + } + return nil + } + + /// The legacy `v2PublicSurfaceResumeSource`: `process-detected` → `manual`. + private func publicResumeSource(_ params: [String: JSONValue]) -> String? { + let source = optionalTrimmedRawString(params, "source") + return source == "process-detected" ? "manual" : source + } + + // MARK: - resume.set + + /// `surface.resume.set` — set (and run the approval flow for) a resume binding. + func surfaceResumeSet(_ params: [String: JSONValue]) -> ControlCallResult { + if let error = surfaceResumeTargetValidationError(params) { return error } + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: Self.surfaceWindowUnavailableMessage, data: nil) + } + guard let command = rawString(params, "command")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !command.isEmpty else { + return .err(code: "invalid_params", message: "Missing command", data: nil) + } + + let source = publicResumeSource(params) + let inputs = ControlSurfaceResumeSetInputs( + name: optionalTrimmedRawString(params, "name"), + kind: optionalTrimmedRawString(params, "kind"), + command: command, + cwd: optionalTrimmedRawString(params, "cwd"), + checkpointID: optionalTrimmedRawString(params, "checkpoint_id") + ?? optionalTrimmedRawString(params, "checkpointId"), + source: source, + environment: stringMap(params, "environment"), + autoResume: source == "agent-hook" ? (bool(params, "auto_resume") ?? false) : false + ) + return surfaceResumeResult( + context?.controlSurfaceResumeSet(routing: routing, inputs: inputs) ?? .setFailed + ) + } + + // MARK: - resume.get + + /// `surface.resume.get` — read a surface's resume binding. + func surfaceResumeGet(_ params: [String: JSONValue]) -> ControlCallResult { + if let error = surfaceResumeTargetValidationError(params) { return error } + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: Self.surfaceWindowUnavailableMessage, data: nil) + } + return surfaceResumeResult( + context?.controlSurfaceResumeGet(routing: routing) ?? .surfaceNotFound + ) + } + + // MARK: - resume.clear + + /// `surface.resume.clear` — clear a surface's resume binding. + func surfaceResumeClear(_ params: [String: JSONValue]) -> ControlCallResult { + if let error = surfaceResumeTargetValidationError(params) { return error } + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: Self.surfaceWindowUnavailableMessage, data: nil) + } + let resolution = context?.controlSurfaceResumeClear( + routing: routing, + expectedCheckpointID: optionalTrimmedRawString(params, "checkpoint_id") + ?? optionalTrimmedRawString(params, "checkpointId"), + expectedSource: optionalTrimmedRawString(params, "source") + ) ?? .surfaceNotFound + return surfaceResumeResult(resolution) + } + + /// Shapes the shared `surface.resume.*` result. + private func surfaceResumeResult(_ resolution: ControlSurfaceResumeResolution) -> ControlCallResult { + switch resolution { + case .windowUnavailable: + // The coordinator already guards `unavailable` before calling the seam; + // this mirrors the legacy fallback for completeness. + return .err(code: "unavailable", message: Self.surfaceWindowUnavailableMessage, data: nil) + case .surfaceNotFound: + return .err(code: "not_found", message: "Surface not found", data: nil) + case .emptyResumeCommand: + return .err(code: "invalid_params", message: "Resume command is empty", data: nil) + case .setFailed: + return .err(code: "internal_error", message: "Failed to set resume binding", data: nil) + case .result(let snapshot): + return .ok(.object([ + "window_id": orNull(snapshot.windowID?.uuidString), + "window_ref": ref(.window, snapshot.windowID), + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "pane_id": orNull(snapshot.paneID?.uuidString), + "pane_ref": ref(.pane, snapshot.paneID), + "surface_id": .string(snapshot.surfaceID.uuidString), + "surface_ref": ref(.surface, snapshot.surfaceID), + "cleared": .bool(snapshot.cleared), + "resume_binding": surfaceResumeBindingPayload(snapshot.binding), + ])) + } + } + + /// The byte-faithful twin of `v2SurfaceResumeBindingPayload`: a `null` binding + /// becomes JSON `null`, else the resume-binding object. Shared by `surface.list` + /// rows and the resume results. + func surfaceResumeBindingPayload(_ binding: ControlSurfaceResumeBinding?) -> JSONValue { + guard let binding else { return .null } + let environment: JSONValue = binding.environment.map { env in + .object(env.mapValues { .string($0) }) + } ?? .null + return .object([ + "name": orNull(binding.name), + "kind": orNull(binding.kind), + "command": .string(binding.command), + "cwd": orNull(binding.cwd), + "checkpoint_id": orNull(binding.checkpointID), + "source": orNull(binding.source), + "environment": environment, + "auto_resume": .bool(binding.autoResume), + "approval_policy": orNull(binding.approvalPolicyRawValue), + "approval_record_id": orNull(binding.approvalRecordID), + "updated_at": .double(binding.updatedAt), + ]) + } + + // MARK: - report_tty + + /// `surface.report_tty` — record a reported TTY name. + func surfaceReportTTY(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let requestedSurfaceID = uuid(params, "surface_id") + if hasNonNull(params, "surface_id"), requestedSurfaceID == nil { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let ttyName = rawString(params, "tty_name")? + .trimmingCharacters(in: .whitespacesAndNewlines), !ttyName.isEmpty else { + return .err(code: "invalid_params", message: "Missing tty_name", data: nil) + } + + let resolution = context?.controlSurfaceReportTTY( + workspaceID: workspaceID, + requestedSurfaceID: requestedSurfaceID, + ttyName: ttyName + ) ?? .workspaceNotFound + let requestedSurfaceData = surfaceReportSurfaceFields( + workspaceID: workspaceID, + requestedSurfaceID: requestedSurfaceID + ) + switch resolution { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: .object(requestedSurfaceData)) + case .surfaceNotFound: + return .err(code: "not_found", message: "Surface not found", data: .object(requestedSurfaceData)) + case .pending: + var payload = requestedSurfaceData + payload["tty_name"] = .string(ttyName) + payload["pending"] = .bool(true) + return .ok(.object(payload)) + case .recorded(let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "tty_name": .string(ttyName), + ])) + } + } + + // MARK: - report_shell_state + + /// `surface.report_shell_state` — record reported shell-activity state. + func surfaceReportShellState(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let requestedSurfaceID = uuid(params, "surface_id") + if hasNonNull(params, "surface_id"), requestedSurfaceID == nil { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let rawState = rawString(params, "state") + ?? rawString(params, "shell_state") + ?? rawString(params, "activity") + guard let rawState, + let stateRawValue = context?.controlSurfaceParseShellActivityState(rawState) else { + return .err(code: "invalid_params", message: "state must be prompt, running, or unknown", data: nil) + } + + let resolution = context?.controlSurfaceReportShellState( + workspaceID: workspaceID, + requestedSurfaceID: requestedSurfaceID, + stateRawValue: stateRawValue + ) ?? .pending + switch resolution { + case .explicit(let surfaceID, let published): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "state": .string(stateRawValue), + "published": .bool(published), + ])) + case .pending: + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .null, + "surface_ref": .null, + "state": .string(stateRawValue), + "published": .bool(true), + "pending": .bool(true), + ])) + } + } + + // MARK: - ports_kick + + /// `surface.ports_kick` — kick the port scanner for a surface. + func surfacePortsKick(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let requestedSurfaceID = uuid(params, "surface_id") + if hasNonNull(params, "surface_id"), requestedSurfaceID == nil { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let reasonRawValue: String + if let rawReason = rawString(params, "reason") { + guard let parsed = context?.controlSurfaceParsePortScanKickReason(rawReason) else { + return .err(code: "invalid_params", message: "reason must be command or refresh", data: nil) + } + reasonRawValue = parsed + } else { + reasonRawValue = "command" + } + + let resolution = context?.controlSurfacePortsKick( + workspaceID: workspaceID, + requestedSurfaceID: requestedSurfaceID, + reasonRawValue: reasonRawValue + ) ?? .workspaceNotFound + let requestedSurfaceData = surfaceReportSurfaceFields( + workspaceID: workspaceID, + requestedSurfaceID: requestedSurfaceID + ) + switch resolution { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: .object(requestedSurfaceData)) + case .surfaceNotFound: + return .err(code: "not_found", message: "Surface not found", data: .object(requestedSurfaceData)) + case .pending: + var payload = requestedSurfaceData + payload["reason"] = .string(reasonRawValue) + payload["pending"] = .bool(true) + return .ok(.object(payload)) + case .kicked(let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "reason": .string(reasonRawValue), + ])) + } + } + + /// The shared workspace/requested-surface field block the report/kick payloads + /// echo (the legacy `v2OrNull` requested-surface shape). + private func surfaceReportSurfaceFields( + workspaceID: UUID, + requestedSurfaceID: UUID? + ) -> [String: JSONValue] { + [ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": orNull(requestedSurfaceID?.uuidString), + "surface_ref": ref(.surface, requestedSurfaceID), + ] + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift new file mode 100644 index 000000000000..d8eced88927a --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift @@ -0,0 +1,871 @@ +internal import Foundation + +/// The workspace domain (the non-group `workspace.*` methods), lifted +/// byte-faithfully from the former `TerminalController.v2Workspace*` bodies. +/// Each payload is built directly as a ``JSONValue`` (the typed twin of the +/// legacy `[String: Any]` dictionaries); the resulting Foundation object is +/// identical, so the encoded wire bytes match. +/// +/// The `workspace.group.*` methods live in `+WorkspaceGroup.swift`; +/// `workspace.action` / `extension.sidebar.snapshot` and the worker-lane +/// `workspace.remote.pty_*` (sessions/close/detach/bridge/resize) methods stay +/// on the app-side dispatcher. +extension ControlCommandCoordinator { + /// Dispatches the non-group workspace methods this coordinator owns; returns + /// `nil` for anything else so the core `handle(_:)` can fall through. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not an owned workspace method. + func handleWorkspace(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "workspace.list": + return workspaceList(request.params) + case "workspace.create": + return workspaceCreate(request.params) + case "workspace.select": + return workspaceSelect(request.params) + case "workspace.current": + return workspaceCurrent(request.params) + case "workspace.close": + return workspaceClose(request.params) + case "workspace.move_to_window": + return workspaceMoveToWindow(request.params) + case "workspace.reorder": + return workspaceReorder(request.params) + case "workspace.reorder_many": + return workspaceReorderMany(request.params) + case "workspace.prompt_submit": + return workspacePromptSubmit(request.params) + case "workspace.rename": + return workspaceRename(request.params) + case "workspace.next": + return workspaceNext(request.params) + case "workspace.previous": + return workspacePrevious(request.params) + case "workspace.last": + return workspaceLast(request.params) + case "workspace.equalize_splits": + return workspaceEqualizeSplits(request.params) + case "workspace.remote.configure": + return workspaceRemoteConfigure(request.params) + case "workspace.remote.foreground_auth_ready": + return workspaceRemoteForegroundAuthReady(request.params) + case "workspace.remote.reconnect": + return workspaceRemoteReconnect(request.params) + case "workspace.remote.disconnect": + return workspaceRemoteDisconnect(request.params) + case "workspace.remote.status": + return workspaceRemoteStatus(request.params) + case "workspace.remote.pty_attach_end": + return workspaceRemotePTYAttachEnd(request.params) + case "workspace.remote.terminal_session_end": + return workspaceRemoteTerminalSessionEnd(request.params) + default: + return nil + } + } + + // MARK: - Summary payload + + /// Builds one workspace's summary payload (the legacy + /// `v2WorkspaceSummaryPayload`), minting the workspace ref and writing the + /// caller-supplied `selected` / optional `index`. + private func workspaceSummaryPayload( + _ summary: ControlWorkspaceSummary, + index: Int?, + selected: Bool + ) -> JSONValue { + var object: [String: JSONValue] = [ + "id": .string(summary.id.uuidString), + "ref": ref(.workspace, summary.id), + "title": .string(summary.title), + "description": orNull(summary.customDescription), + "selected": .bool(selected), + "pinned": .bool(summary.isPinned), + "listening_ports": .array(summary.listeningPorts.map { .int(Int64($0)) }), + "remote": summary.remoteStatus, + "current_directory": orNull(summary.currentDirectory), + "custom_color": orNull(summary.customColor), + "latest_conversation_message": orNull(summary.latestConversationMessage), + "latest_submitted_message": orNull(summary.latestSubmittedMessage), + "latest_submitted_at": orNull(summary.latestSubmittedAt), + ] + if let index { + object["index"] = .int(Int64(index)) + } + return .object(object) + } + + // MARK: - List / current + + /// `workspace.list` — every workspace in the resolved window. + func workspaceList(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = context?.controlWorkspaceList(routing: routingSelectors(params)) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .resolved(let windowID, let workspaces, let selectedIndex): + let rows: [JSONValue] = workspaces.enumerated().map { index, summary in + workspaceSummaryPayload(summary, index: index, selected: index == selectedIndex) + } + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspaces": .array(rows), + ])) + } + } + + /// `workspace.current` — the selected workspace in the resolved window. + func workspaceCurrent(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = context?.controlWorkspaceCurrent(routing: routingSelectors(params)) + ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .noWorkspaceSelected: + return .err(code: "not_found", message: "No workspace selected", data: nil) + case .resolved(let windowID, let workspaceID, let index, let summary): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "workspace": workspaceSummaryPayload(summary, index: index, selected: true), + ])) + } + } + + // MARK: - Create + + /// `workspace.create` — create a workspace. + func workspaceCreate(_ params: [String: JSONValue]) -> ControlCallResult { + let workingDirectory = optionalTrimmedRawString(params, "working_directory") + let initialCommand = optionalTrimmedRawString(params, "initial_command") + let title = optionalTrimmedRawString(params, "title") + let description = rawString(params, "description") + + let rawInitialEnv = stringMap(params, "initial_env") ?? [:] + let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + result[key] = pair.value + } + + let inputs = ControlWorkspaceCreateInputs( + title: title, + description: description, + workingDirectory: workingDirectory, + rawCWD: params["cwd"], + initialCommand: initialCommand, + initialEnv: initialEnv, + rawLayout: params["layout"], + focusRequested: bool(params, "focus") ?? false, + eagerLoadTerminal: bool(params, "eager_load_terminal"), + autoRefreshMetadata: bool(params, "auto_refresh_metadata") + ) + + let resolution = context?.controlCreateWorkspace( + routing: routingSelectors(params), + inputs: inputs + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidParams(let message): + return .err(code: "invalid_params", message: message, data: nil) + case .creationFailed: + return .err(code: "internal_error", message: "Failed to create workspace", data: nil) + case .resolved(let windowID, let workspaceID, let surfaceID): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": orNull(surfaceID?.uuidString), + "surface_ref": ref(.surface, surfaceID), + ])) + } + } + + // MARK: - Select / close / move + + /// `workspace.select` — select a workspace by id. + func workspaceSelect(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let resolution = context?.controlSelectWorkspace( + routing: routingSelectors(params), + workspaceID: workspaceID + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .notFound: + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + case .resolved(let windowID): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + } + } + + /// `workspace.close` — close a workspace by id. + func workspaceClose(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + let resolution = context?.controlCloseWorkspace( + routing: routingSelectors(params), + workspaceID: workspaceID + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .protected(let windowID): + let message = context?.controlWorkspaceStrings().closeProtected ?? "" + return .err(code: "protected", message: message, data: .object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pinned": .bool(true), + ])) + case .notFound: + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + case .resolved(let windowID): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + } + } + + /// `workspace.move_to_window` — move a workspace to another window. + func workspaceMoveToWindow(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let windowID = uuid(params, "window_id") else { + return .err(code: "invalid_params", message: "Missing or invalid window_id", data: nil) + } + let focusRequested = bool(params, "focus") ?? false + guard let resolution = context?.controlMoveWorkspaceToWindow( + workspaceID: workspaceID, + windowID: windowID, + focusRequested: focusRequested + ) else { + return .err(code: "internal_error", message: "Failed to move workspace", data: nil) + } + switch resolution { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + ])) + case .windowNotFound: + return .err(code: "not_found", message: "Window not found", data: .object([ + "window_id": .string(windowID.uuidString), + ])) + case .resolved: + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "window_id": .string(windowID.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + // MARK: - Reorder + + /// Builds one reorder plan item's payload (the legacy + /// `v2WorkspaceReorderPlanPayload`). + private func workspaceReorderPlanPayload( + _ plan: ControlWorkspaceReorderPlanItem, + windowID: UUID? + ) -> JSONValue { + .object([ + "workspace_id": .string(plan.workspaceID.uuidString), + "workspace_ref": ref(.workspace, plan.workspaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "from_index": .int(Int64(plan.fromIndex)), + "to_index": .int(Int64(plan.toIndex)), + ]) + } + + /// `workspace.reorder` — move one workspace to an index/relative target. + func workspaceReorder(_ params: [String: JSONValue]) -> ControlCallResult { + guard context?.controlWorkspaceRoutingResolvesTabManager(routing: routingSelectors(params)) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + + let index = int(params, "index") + let beforeID = uuid(params, "before_workspace_id") + let afterID = uuid(params, "after_workspace_id") + let dryRun = bool(params, "dry_run") ?? false + + 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 + ) + } + + let resolution = context?.controlReorderWorkspace( + routing: routingSelectors(params), + workspaceID: workspaceID, + toIndex: index, + beforeWorkspaceID: beforeID, + afterWorkspaceID: afterID, + dryRun: dryRun + ) ?? .notFound + switch resolution { + case .notFound: + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + ])) + case .resolved(let windowID, let plan): + var object: [String: JSONValue] = [ + "workspace_id": .string(plan.workspaceID.uuidString), + "workspace_ref": ref(.workspace, plan.workspaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "from_index": .int(Int64(plan.fromIndex)), + "to_index": .int(Int64(plan.toIndex)), + ] + object["dry_run"] = .bool(dryRun) + object["index"] = .int(Int64(plan.toIndex)) + object["plan"] = .array([workspaceReorderPlanPayload(plan, windowID: windowID)]) + object["events"] = (!dryRun && plan.fromIndex != plan.toIndex) + ? .array([workspaceReorderPlanPayload(plan, windowID: windowID)]) + : .array([]) + return .ok(.object(object)) + } + } + + /// `workspace.reorder_many` — apply a desired workspace order. + func workspaceReorderMany(_ params: [String: JSONValue]) -> ControlCallResult { + let strings = context?.controlWorkspaceStrings() + + let rawOrder = workspaceReorderManyOrder(params) + if let invalid = rawOrder.invalidValue { + return .err( + code: "invalid_params", + message: strings?.reorderManyInvalidWorkspace ?? "", + data: .object(["workspace": .string(invalid)]) + ) + } + let order = rawOrder.order + guard !order.isEmpty else { + return .err( + code: "invalid_params", + message: strings?.reorderManyMissingOrder ?? "", + data: nil + ) + } + + var workspaceIDs: [UUID] = [] + workspaceIDs.reserveCapacity(order.count) + for raw in order { + guard let workspaceID = uuidAny(.string(raw)) else { + return .err( + code: "invalid_params", + message: strings?.reorderManyInvalidWorkspace ?? "", + data: .object(["workspace": .string(raw)]) + ) + } + workspaceIDs.append(workspaceID) + } + + let dryRun = bool(params, "dry_run") ?? false + let resolution = context?.controlReorderWorkspacesMany( + routing: routingSelectors(params), + workspaceIDs: workspaceIDs, + dryRun: dryRun + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err( + code: "unavailable", + message: strings?.reorderManyTabManagerUnavailable ?? "", + data: nil + ) + case .duplicateWorkspace(let workspaceID): + return .err( + code: "invalid_params", + message: strings?.reorderManyDuplicateWorkspace ?? "", + data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ]) + ) + case .workspaceNotFound(let workspaceID): + return .err( + code: "not_found", + message: strings?.reorderManyWorkspaceNotFound ?? "", + data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ]) + ) + case .resolved(let windowID, let plans): + let planPayloads = plans.map { workspaceReorderPlanPayload($0, windowID: windowID) } + let events: [JSONValue] = dryRun + ? [] + : zip(plans, planPayloads).compactMap { plan, payload in + plan.fromIndex != plan.toIndex ? payload : nil + } + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "dry_run": .bool(dryRun), + "plan": .array(planPayloads), + "events": .array(events), + ])) + } + } + + /// Parses the `workspace_ids` / `order` params for `workspace.reorder_many` + /// (the legacy `v2WorkspaceReorderManyOrder`), returning the trimmed order + /// or the JSON-encoded invalid value description. + private func workspaceReorderManyOrder( + _ params: [String: JSONValue] + ) -> (order: [String], invalidValue: String?) { + if let raw = params["workspace_ids"], !isNull(raw) { + switch raw { + case .array(let values): + var strings: [String] = [] + strings.reserveCapacity(values.count) + for item in values { + guard case .string(let value) = item else { + return ([], invalidValueDescription( + item, + fallback: "" + )) + } + strings.append(value) + } + return normalizeReorderManyOrder(strings) + case .string(let value): + return normalizeReorderManyOrder([value]) + default: + return ([], invalidValueDescription( + raw, + fallback: "" + )) + } + } + + guard let order = params["order"], !isNull(order) else { return ([], nil) } + guard case .string(let orderString) = order else { + return ([], invalidValueDescription( + order, + fallback: "" + )) + } + let refs = orderString + .split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + return normalizeReorderManyOrder(refs) + } + + /// The legacy `v2NormalizeWorkspaceReorderManyOrder`: trimmed entries, with + /// the first empty entry reported as the invalid value (its raw form). + private func normalizeReorderManyOrder( + _ rawItems: [String] + ) -> (order: [String], invalidValue: String?) { + var order: [String] = [] + order.reserveCapacity(rawItems.count) + for raw in rawItems { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return ([], raw) + } + order.append(trimmed) + } + return (order, nil) + } + + /// The legacy `v2WorkspaceReorderManyInvalidValueDescription`: the JSON + /// encoding of `{"value": }`, or the fallback when it can't encode. + private func invalidValueDescription(_ value: JSONValue, fallback: String) -> String { + let wrapped: [String: Any] = ["value": value.foundationObject] + guard JSONSerialization.isValidJSONObject(wrapped), + let data = try? JSONSerialization.data(withJSONObject: wrapped, options: []), + let encoded = String(data: data, encoding: .utf8) else { + return fallback + } + return encoded + } + + // MARK: - Prompt submit / rename + + /// `workspace.prompt_submit` — submit a prompt into a workspace. + func workspacePromptSubmit(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + + let messageKeys = ["message", "prompt", "text", "body"] + for key in messageKeys { + guard let raw = params[key], !isNull(raw) else { continue } + guard case .string = raw else { + return .err(code: "invalid_params", message: "\(key) must be a string", data: nil) + } + } + let message = messageKeys.lazy.compactMap { self.rawString(params, $0) }.first + + let resolution = context?.controlSubmitWorkspacePrompt( + routing: routingSelectors(params), + workspaceID: workspaceID, + message: message + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .notFound: + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + ])) + case .resolved(let windowID, let iMessageModeEnabled, let messageRecorded, let reordered, let index, let preview): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "i_message_mode_enabled": .bool(iMessageModeEnabled), + "message_recorded": .bool(messageRecorded), + "message_preview": orNull(preview), + "reordered": .bool(reordered), + "index": .int(Int64(index)), + ])) + } + } + + /// `workspace.rename` — set a workspace's custom title. + func workspaceRename(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let title = string(params, "title") else { + return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) + } + let resolution = context?.controlRenameWorkspace( + routing: routingSelectors(params), + workspaceID: workspaceID, + title: title + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .notFound: + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + case .resolved(let windowID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "title": .string(title), + ])) + } + } + + // MARK: - Navigation + + /// Shapes the shared navigation result for next/previous/last. + private func workspaceNavigationResult( + _ resolution: ControlWorkspaceNavigationResolution?, + notFoundMessage: String + ) -> ControlCallResult { + switch resolution ?? .tabManagerUnavailable { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .notFound: + return .err(code: "not_found", message: notFoundMessage, data: nil) + case .resolved(let workspaceID, let windowID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ])) + } + } + + /// `workspace.next` — select the next workspace. + func workspaceNext(_ params: [String: JSONValue]) -> ControlCallResult { + workspaceNavigationResult( + context?.controlSelectNextWorkspace(routing: routingSelectors(params)), + notFoundMessage: "No workspace selected" + ) + } + + /// `workspace.previous` — select the previous workspace. + func workspacePrevious(_ params: [String: JSONValue]) -> ControlCallResult { + workspaceNavigationResult( + context?.controlSelectPreviousWorkspace(routing: routingSelectors(params)), + notFoundMessage: "No workspace selected" + ) + } + + /// `workspace.last` — navigate to the previously-selected workspace. + func workspaceLast(_ params: [String: JSONValue]) -> ControlCallResult { + workspaceNavigationResult( + context?.controlSelectLastWorkspace(routing: routingSelectors(params)), + notFoundMessage: "No previous workspace in history" + ) + } + + // MARK: - Equalize + + /// `workspace.equalize_splits` — equalize the resolved workspace's splits. + func workspaceEqualizeSplits(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = context?.controlEqualizeWorkspaceSplits( + routing: routingSelectors(params), + orientationFilter: string(params, "orientation") + ) ?? .tabManagerUnavailable + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .notFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .resolved(let workspaceID, let equalized): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "equalized": .bool(equalized), + ])) + } + } + + // MARK: - Remote + + /// Shapes the shared remote-mutation result for disconnect / reconnect / + /// foreground_auth_ready / status. + private func workspaceRemoteResult(_ resolution: ControlWorkspaceRemoteResolution?) -> ControlCallResult { + switch resolution ?? .missingWorkspaceID { + case .missingWorkspaceID: + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + case .notFound(let workspaceID): + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + case .notConfigured(let workspaceID): + return .err(code: "invalid_state", message: "Remote workspace is not configured", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + case .resolved(let windowID, let workspaceID, let remoteStatus): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "remote": remoteStatus, + ])) + } + } + + /// Resolves the explicit-or-fallback workspace id for the remote methods, + /// reproducing the legacy `requestedWorkspaceId ?? selectedTabId` rule and + /// its two `invalid_params` failures. + private func remoteWorkspaceID( + _ params: [String: JSONValue] + ) -> (workspaceID: UUID?, error: ControlCallResult?) { + let requested = uuid(params, "workspace_id") + if hasNonNull(params, "workspace_id"), requested == nil { + return (nil, .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)) + } + let resolved = context?.controlResolveRemoteWorkspaceID( + routing: routingSelectors(params), + requestedWorkspaceID: requested + ) + guard let resolved else { + return (nil, .err(code: "invalid_params", message: "Missing workspace_id", data: nil)) + } + return (resolved, nil) + } + + /// `workspace.remote.configure` — configure a workspace's remote connection. + func workspaceRemoteConfigure(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = remoteWorkspaceID(params) + if let error = resolution.error { return error } + guard let workspaceID = resolution.workspaceID else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + return context?.controlConfigureWorkspaceRemote(params: params, workspaceID: workspaceID) + ?? .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + ])) + } + + /// `workspace.remote.disconnect` — disconnect a workspace's remote. + func workspaceRemoteDisconnect(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = remoteWorkspaceID(params) + if let error = resolution.error { return error } + guard let workspaceID = resolution.workspaceID else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + let clearConfiguration = bool(params, "clear") ?? false + return workspaceRemoteResult(context?.controlDisconnectWorkspaceRemote( + workspaceID: workspaceID, + clearConfiguration: clearConfiguration + )) + } + + /// `workspace.remote.reconnect` — reconnect a workspace's remote. + func workspaceRemoteReconnect(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = remoteWorkspaceID(params) + if let error = resolution.error { return error } + guard let workspaceID = resolution.workspaceID else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + return workspaceRemoteResult(context?.controlReconnectWorkspaceRemote(workspaceID: workspaceID)) + } + + /// `workspace.remote.foreground_auth_ready` — arm/continue a pending connect. + func workspaceRemoteForegroundAuthReady(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = remoteWorkspaceID(params) + if let error = resolution.error { return error } + guard let workspaceID = resolution.workspaceID else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + // Legacy `v2RawString(...)?.trimmingCharacters(...)`: trimmed, but an + // empty string stays "" (NOT nil), so use the raw-trim, not the + // empty-to-nil variant. + let token = rawString(params, "foreground_auth_token")? + .trimmingCharacters(in: .whitespacesAndNewlines) + return workspaceRemoteResult(context?.controlWorkspaceRemoteForegroundAuthReady( + workspaceID: workspaceID, + foregroundAuthToken: token + )) + } + + /// `workspace.remote.status` — read a workspace's remote status. + func workspaceRemoteStatus(_ params: [String: JSONValue]) -> ControlCallResult { + let resolution = remoteWorkspaceID(params) + if let error = resolution.error { return error } + guard let workspaceID = resolution.workspaceID else { + return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + } + return workspaceRemoteResult(context?.controlWorkspaceRemoteStatus(workspaceID: workspaceID)) + } + + /// `workspace.remote.pty_attach_end` — record a remote PTY attach end. + func workspaceRemotePTYAttachEnd(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let sessionID = optionalTrimmedRawString(params, "session_id") else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) + } + + let resolution = context?.controlWorkspaceRemotePTYAttachEnd( + workspaceID: workspaceID, + surfaceID: surfaceID, + sessionID: sessionID + ) ?? .notFound + switch resolution { + case .notFound: + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "session_id": .string(sessionID), + "workspace_found": .bool(false), + "cleared_remote_pty_session": .bool(false), + "untracked_remote_terminal": .bool(false), + ])) + case .resolved(let windowID, let resolvedWorkspaceID, let cleared, let untracked, let remoteStatus): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(resolvedWorkspaceID.uuidString), + "workspace_ref": ref(.workspace, resolvedWorkspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "session_id": .string(sessionID), + "workspace_found": .bool(true), + "cleared_remote_pty_session": .bool(cleared), + "untracked_remote_terminal": .bool(untracked), + "remote": remoteStatus, + ])) + } + } + + /// `workspace.remote.terminal_session_end` — record a remote terminal + /// session end. + func workspaceRemoteTerminalSessionEnd(_ params: [String: JSONValue]) -> ControlCallResult { + guard let workspaceID = uuid(params, "workspace_id") else { + return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let relayPort = strictInt(params, "relay_port"), relayPort > 0, relayPort <= 65535 else { + return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) + } + + let resolution = context?.controlWorkspaceRemoteTerminalSessionEnd( + workspaceID: workspaceID, + surfaceID: surfaceID, + relayPort: relayPort + ) ?? .notFound + switch resolution { + case .notFound: + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "relay_port": .int(Int64(relayPort)), + ])) + case .resolved(let windowID, let resolvedWorkspaceID, let remoteStatus): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(resolvedWorkspaceID.uuidString), + "workspace_ref": ref(.workspace, resolvedWorkspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "relay_port": .int(Int64(relayPort)), + "remote": remoteStatus, + ])) + } + } + + /// `v2HasNonNullParam`-style null test on a typed value. + private func isNull(_ value: JSONValue) -> Bool { + if case .null = value { return true } + return false + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift index 642a1db6975c..14dd279d0a27 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift @@ -70,6 +70,8 @@ public final class ControlCommandCoordinator { if let result = handleWorkspaceGroup(request) { return result } if let result = handlePane(request) { return result } if let result = handleMobileHost(request) { return result } + if let result = handleWorkspace(request) { return result } + if let result = handleSurface(request) { return result } return nil } diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceBrowserDisabledOutcome.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceBrowserDisabledOutcome.swift new file mode 100644 index 000000000000..84aa161a1e2b --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceBrowserDisabledOutcome.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The outcome of the legacy `v2BrowserDisabledExternalOpenResult`, shared by +/// `surface.split` and `surface.create` when a browser surface is requested while +/// the cmux browser is disabled. +/// +/// Each case maps byte-for-byte onto the legacy result the helper produced. The +/// `openedExternally` payload carries the enclosing window (the only resolved id; +/// the workspace/pane/surface fields are all `null` in the legacy payload). +public enum ControlSurfaceBrowserDisabledOutcome: Sendable, Equatable { + /// A `url` param was present but did not parse (legacy `invalid_params` / + /// "Invalid URL", `data: {"url": rawURL}`). + case invalidURL(rawURL: String) + /// No `url` param at all (legacy `browser_disabled` / "cmux browser is + /// disabled", `data: nil`). + case noURL + /// `NSWorkspace.open` failed (legacy `external_open_failed` / "Failed to open + /// URL externally", `data: {"url": …}`). + case externalOpenFailed(url: String) + /// The URL opened externally (legacy `.ok` with the `external_browser_disabled` + /// placement payload). Carries the enclosing window and the opened URL. + case openedExternally(windowID: UUID?, url: String) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceClearHistoryResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceClearHistoryResolution.swift new file mode 100644 index 000000000000..3bf6da2c9370 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceClearHistoryResolution.swift @@ -0,0 +1,27 @@ +public import Foundation + +/// The outcome of `surface.clear_history`, preserving the legacy body's distinct +/// failures and the echoed identity. +/// +/// The coordinator signals `unavailable`; the app resolves the workspace and +/// surface, runs the `clear_screen` binding action, and returns this resolution. +public enum ControlSurfaceClearHistoryResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// A `surface_id` param was present but did not parse (legacy `not_found` / + /// "Surface not found for the given surface_id"). + case surfaceNotFoundForID + /// No surface resolved and none focused (legacy `not_found` / "No focused + /// surface"). + case noFocusedSurface + /// The resolved surface is not a terminal (legacy `invalid_params` / "Surface + /// is not a terminal", `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotTerminal(UUID) + /// The `clear_screen` binding action is unavailable (legacy `not_supported` / + /// "clear_screen binding action is unavailable"). + case bindingActionUnavailable + /// The history was cleared. Carries the echoed identity. + case cleared(windowID: UUID?, workspaceID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCloseResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCloseResolution.swift new file mode 100644 index 000000000000..a6cffd438fc0 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCloseResolution.swift @@ -0,0 +1,27 @@ +public import Foundation + +/// The outcome of `surface.close`, preserving the legacy body's distinct failures +/// and the closed identity. +/// +/// The coordinator signals `unavailable`; the app resolves the workspace and +/// surface, force-closes it (the socket API is non-interactive), and returns this. +public enum ControlSurfaceCloseResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// No surface resolved and none focused (legacy `not_found` / "No focused + /// surface"). + case noFocusedSurface + /// The surface id did not exist (legacy `not_found` / "Surface not found", + /// `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotFound(UUID) + /// The workspace has only one surface left (legacy `invalid_state` / "Cannot + /// close the last surface"). + case lastSurface + /// The close call failed (legacy `internal_error` / "Failed to close surface", + /// `data: {"surface_id": …}`). Carries the surface id. + case closeFailed(UUID) + /// The surface was closed. Carries the echoed identity. + case closed(windowID: UUID?, workspaceID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceContext.swift new file mode 100644 index 000000000000..d1f3e0803677 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceContext.swift @@ -0,0 +1,341 @@ +public import Foundation + +/// The surface-domain slice of the control-command seam (a constituent of the +/// ``ControlCommandContext`` umbrella). +/// +/// The app target (today `TerminalController`, the interim composition owner) +/// conforms by reading live `TabManager` / `Workspace` / `TerminalPanel` / +/// `BrowserPanel` state, the Ghostty surfaces, the `TerminalSurfaceRegistry`, +/// and the `SurfaceResumeApprovalStore`. Every method is `@MainActor` because its +/// conformer and the coordinator both live on the main actor, so these are plain +/// in-isolation calls — the per-read `v2MainSync` hops the legacy command bodies +/// used disappear once the domain moves onto the coordinator. +/// +/// No app types cross the seam: reads return `Control*` snapshot values, mutations +/// take pre-parsed selectors/ids and return small Sendable resolution enums, and +/// every blocking `NSAlert` and `String(localized:)` resolves inside the app +/// conformance (app bundle). The lone exception is ``controlDebugTerminals`` — its +/// payload is dozens of irreducibly app-coupled `NSWindow`/`NSView`/Ghostty +/// pointer fields, so the app returns it as a bridged ``JSONValue`` (the same +/// single-method passthrough `workspace.remote.configure` uses). +@MainActor +public protocol ControlSurfaceContext: AnyObject { + /// Whether a TabManager resolves for surface routing, used to distinguish the + /// `unavailable` failure from the `not_found` failure. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: Whether a TabManager resolved. + func controlSurfaceRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool + + // MARK: - list / current / health + + /// Snapshots the resolved workspace's surfaces for `surface.list`. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: The list snapshot, or `nil` when no workspace resolves. + func controlSurfaceList(routing: ControlRoutingSelectors) -> ControlSurfaceListSnapshot? + + /// Snapshots the current surface for `surface.current`. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: The current snapshot, or `nil` when no workspace resolves. + func controlSurfaceCurrent(routing: ControlRoutingSelectors) -> ControlSurfaceCurrentSnapshot? + + /// Snapshots surface render health for `surface.health`. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: The health snapshot, or `nil` when no workspace resolves. + func controlSurfaceHealth(routing: ControlRoutingSelectors) -> ControlSurfaceHealthSnapshot? + + /// The app-bundle-resolved localized error strings for `surface.respawn`. The + /// app resolves each `String(localized:)` with the identical key + default + /// value so the package never binds them to the wrong bundle. + /// + /// - Returns: The respawn strings. + func controlSurfaceRespawnStrings() -> ControlSurfaceRespawnStrings + + // MARK: - focus / split / respawn / create / close + + /// Focuses a surface for `surface.focus`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The surface to focus. + /// - Returns: The focus resolution. + func controlSurfaceFocus( + routing: ControlRoutingSelectors, + surfaceID: UUID + ) -> ControlSurfaceFocusResolution + + /// Creates a split surface for `surface.split`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - inputs: The pre-parsed (and pre-validated) split inputs. + /// - Returns: The split resolution. + func controlSurfaceSplit( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceSplitInputs + ) -> ControlSurfaceSplitResolution + + /// Respawns a terminal surface for `surface.respawn`. The coordinator selects + /// each localized error message from ``controlSurfaceRespawnStrings()``; this + /// returns only the discriminator and ids. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - inputs: The pre-parsed (and pre-validated, including the resolved + /// focus) respawn inputs. + /// - Returns: The respawn resolution. + func controlSurfaceRespawn( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceRespawnInputs + ) -> ControlSurfaceRespawnResolution + + /// Creates a surface for `surface.create`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - inputs: The pre-parsed create inputs. + /// - Returns: The create resolution. + func controlSurfaceCreate( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceCreateInputs + ) -> ControlSurfaceCreateResolution + + /// Closes a surface for `surface.close`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The explicit `surface_id`, or `nil` for the focused surface. + /// - Returns: The close resolution. + func controlSurfaceClose( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceCloseResolution + + // MARK: - move / reorder + + /// Moves a surface for `surface.move`, delegating to the shared + /// surface-move logic, and bridges the result to a ``ControlCallResult``. + /// + /// The whole body is app-typed end to end (it walks windows/workspaces/panes + /// and mutates Bonsplit), so the coordinator passes the raw params through and + /// the app returns the fully shaped result (forwarding the still-app-side + /// `v2SurfaceMove`, exactly as `pane.join` does). + /// + /// - Parameter params: The raw command params. + /// - Returns: The fully shaped call result. + func controlSurfaceMove(params: [String: JSONValue]) -> ControlCallResult + + /// Reorders a surface within its pane for `surface.reorder`. + /// + /// - Parameters: + /// - surfaceID: The surface to reorder. + /// - inputs: The pre-parsed (and pre-validated, exactly-one-target) reorder + /// inputs. + /// - requestedFocus: Whether the request asked to focus the surface. + /// - Returns: The reorder resolution. + func controlSurfaceReorder( + surfaceID: UUID, + inputs: ControlSurfaceReorderInputs, + requestedFocus: Bool + ) -> ControlSurfaceReorderResolution + + // MARK: - refresh / clear_history / trigger_flash + + /// Force-refreshes every terminal surface in the workspace for + /// `surface.refresh`. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: The refresh resolution. + func controlSurfaceRefresh(routing: ControlRoutingSelectors) -> ControlSurfaceRefreshResolution + + /// Clears terminal history for `surface.clear_history`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The explicit `surface_id`, or `nil` for the focused surface. + /// - Returns: The clear-history resolution. + func controlSurfaceClearHistory( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceClearHistoryResolution + + /// Triggers the focus flash for `surface.trigger_flash`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The explicit `surface_id`, or `nil` for the focused surface. + /// - Returns: The trigger-flash resolution. + func controlSurfaceTriggerFlash( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceTriggerFlashResolution + + /// The app-bundle-resolved localized terminal-input error strings, shared by + /// `surface.send_text` and `surface.send_key`. The app resolves each + /// `String(localized:)` so the package never binds them to the wrong bundle. + /// + /// - Returns: The input strings. + func controlSurfaceInputStrings() -> ControlSurfaceInputStrings + + // MARK: - send_text / send_key + + /// Sends literal text for `surface.send_text`. The coordinator selects each + /// localized error message from ``controlSurfaceInputStrings()``. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The explicit `surface_id`, or `nil` for the focused surface. + /// - hasSurfaceIDParam: Whether a `surface_id` param was present at all. + /// - text: The text to send. + /// - Returns: The send resolution. + func controlSurfaceSendText( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + text: String + ) -> ControlSurfaceSendResolution + + /// Sends a named key for `surface.send_key`. The coordinator selects each + /// localized error message from ``controlSurfaceInputStrings()``. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The explicit `surface_id`, or `nil` for the focused surface. + /// - hasSurfaceIDParam: Whether a `surface_id` param was present at all. + /// - key: The named key to send. + /// - Returns: The send resolution. + func controlSurfaceSendKey( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + key: String + ) -> ControlSurfaceSendResolution + + // MARK: - read_text + + /// Reads terminal text for `surface.read_text`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The explicit `surface_id`, or `nil` for the focused surface. + /// - hasSurfaceIDParam: Whether a `surface_id` param was present at all. + /// - includeScrollback: Whether to include scrollback. + /// - lineLimit: The optional tail line limit (already validated `> 0`). + /// - Returns: The read-text resolution. + func controlSurfaceReadText( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + includeScrollback: Bool, + lineLimit: Int? + ) -> ControlSurfaceReadTextResolution + + // MARK: - resume.set / get / clear + + /// Sets a resume binding for `surface.resume.set`. The app resolves the + /// target, runs the (possibly blocking, app-bundle-localized) approval flow, + /// and stores the binding. + /// + /// - Parameters: + /// - routing: The routing selectors (with the surface-resume precedence). + /// - inputs: The pre-parsed resume-set inputs. + /// - Returns: The resume resolution. + func controlSurfaceResumeSet( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceResumeSetInputs + ) -> ControlSurfaceResumeResolution + + /// Reads the resume binding for `surface.resume.get`. + /// + /// - Parameter routing: The routing selectors (with the surface-resume + /// precedence). + /// - Returns: The resume resolution. + func controlSurfaceResumeGet(routing: ControlRoutingSelectors) -> ControlSurfaceResumeResolution + + /// Clears the resume binding for `surface.resume.clear`, honoring the optional + /// expected checkpoint/source guards. + /// + /// - Parameters: + /// - routing: The routing selectors (with the surface-resume precedence). + /// - expectedCheckpointID: The optional expected checkpoint guard. + /// - expectedSource: The optional expected source guard. + /// - Returns: The resume resolution. + func controlSurfaceResumeClear( + routing: ControlRoutingSelectors, + expectedCheckpointID: String?, + expectedSource: String? + ) -> ControlSurfaceResumeResolution + + // MARK: - report_tty / report_shell_state / ports_kick + + /// Records a reported TTY name for `surface.report_tty`. + /// + /// - Parameters: + /// - workspaceID: The target workspace. + /// - requestedSurfaceID: The explicit `surface_id`, or `nil` to resolve. + /// - ttyName: The reported (trimmed, non-empty) TTY name. + /// - Returns: The report resolution. + func controlSurfaceReportTTY( + workspaceID: UUID, + requestedSurfaceID: UUID?, + ttyName: String + ) -> ControlSurfaceReportTTYResolution + + /// Parses a raw shell-activity token via the app's + /// `parseReportedShellActivityState`, returning the state's raw value (the + /// coordinator rejects a `nil` result as `invalid_params`). + /// + /// - Parameter rawState: The raw `state`/`shell_state`/`activity` token. + /// - Returns: The parsed state's raw value, or `nil` when unrecognized. + func controlSurfaceParseShellActivityState(_ rawState: String) -> String? + + /// Parses a raw port-scan kick reason via the app's + /// `parseRemotePortScanKickReason`, returning the reason's raw value (the + /// coordinator rejects a `nil` result as `invalid_params`). + /// + /// - Parameter rawReason: The raw `reason` token. + /// - Returns: The parsed reason's raw value, or `nil` when unrecognized. + func controlSurfaceParsePortScanKickReason(_ rawReason: String) -> String? + + /// Records reported shell-activity state for `surface.report_shell_state`. + /// + /// - Parameters: + /// - workspaceID: The target workspace. + /// - requestedSurfaceID: The explicit `surface_id`, or `nil` for the + /// workspace-wide async path. + /// - stateRawValue: The parsed activity state's raw value. + /// - Returns: The report-shell-state resolution. + func controlSurfaceReportShellState( + workspaceID: UUID, + requestedSurfaceID: UUID?, + stateRawValue: String + ) -> ControlSurfaceReportShellStateResolution + + /// Kicks the port scanner for `surface.ports_kick`. + /// + /// - Parameters: + /// - workspaceID: The target workspace. + /// - requestedSurfaceID: The explicit `surface_id`, or `nil` to resolve. + /// - reasonRawValue: The parsed kick reason's raw value. + /// - Returns: The ports-kick resolution. + func controlSurfacePortsKick( + workspaceID: UUID, + requestedSurfaceID: UUID?, + reasonRawValue: String + ) -> ControlSurfacePortsKickResolution + + // MARK: - debug.terminals + + /// Snapshots the global terminal-surface debug table for `debug.terminals`. + /// + /// The payload is dozens of irreducibly app-coupled `NSWindow`/`NSView`/ + /// Ghostty-pointer fields, so the app returns it already shaped as a bridged + /// ``JSONValue`` object (the documented single-method passthrough exception), + /// or `nil` when `AppDelegate` is unavailable. + /// + /// - Returns: The bridged payload, or `nil` when unavailable. + func controlDebugTerminals() -> JSONValue? +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateInputs.swift new file mode 100644 index 000000000000..34a70d472c2f --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateInputs.swift @@ -0,0 +1,61 @@ +public import Foundation + +/// The pre-parsed inputs for `surface.create`, lifted from the legacy +/// `v2SurfaceCreate` body's param parsing. +/// +/// The coordinator parses the raw tokens; the app maps `typeRaw` → `PanelType`, +/// `urlRaw` → `URL`, and (for agent sessions) the raw provider/renderer tokens → +/// the app enums, returning the matching invalid resolution on a bad token. The +/// agent-session option parsing happens only when the type is `agent-session`, +/// exactly as the legacy body. +public struct ControlSurfaceCreateInputs: Sendable, Equatable { + /// The raw `type` token, or `nil` (defaults to terminal). + public let typeRaw: String? + /// The raw `provider_id`/`provider` token, or `nil` (defaults to codex). + public let providerRaw: String? + /// The raw `renderer_kind`/`renderer` token, or `nil` (defaults to react). + public let rendererRaw: String? + /// The raw `url` string, or `nil`. + public let urlRaw: String? + /// The trimmed-non-empty `working_directory`, or `nil`. + public let workingDirectory: String? + /// The trimmed-non-empty `initial_command`, or `nil`. + public let initialCommand: String? + /// The trimmed-non-empty `tmux_start_command`, or `nil`. + public let tmuxStartCommand: String? + /// The trimmed-non-empty `remote_pty_session_id`, or `nil`. + public let remotePTYSessionID: String? + /// The startup environment (`startup_environment`/`initial_env`), `[:]` if none. + public let startupEnvironment: [String: String] + /// The requested target `pane_id`, or `nil` for the focused pane. + public let requestedPaneID: UUID? + /// Whether the request asked to focus the new surface. + public let requestedFocus: Bool + + /// Creates surface-create inputs. + public init( + typeRaw: String?, + providerRaw: String?, + rendererRaw: String?, + urlRaw: String?, + workingDirectory: String?, + initialCommand: String?, + tmuxStartCommand: String?, + remotePTYSessionID: String?, + startupEnvironment: [String: String], + requestedPaneID: UUID?, + requestedFocus: Bool + ) { + self.typeRaw = typeRaw + self.providerRaw = providerRaw + self.rendererRaw = rendererRaw + self.urlRaw = urlRaw + self.workingDirectory = workingDirectory + self.initialCommand = initialCommand + self.tmuxStartCommand = tmuxStartCommand + self.remotePTYSessionID = remotePTYSessionID + self.startupEnvironment = startupEnvironment + self.requestedPaneID = requestedPaneID + self.requestedFocus = requestedFocus + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateResolution.swift new file mode 100644 index 000000000000..7da4f1ed806f --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateResolution.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// The outcome of `surface.create`, preserving the legacy body's distinct failures +/// and the created identity. +/// +/// The coordinator signals `unavailable`; the app maps the type token, validates +/// the agent-session provider/renderer (when the type is `agent-session`), runs the +/// browser-disabled path, resolves the workspace and pane, creates the surface, and +/// returns this resolution. +public enum ControlSurfaceCreateResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// The agent-session `provider` token was invalid (legacy `invalid_params` / + /// "Invalid provider (codex|claude|opencode)", `data: {"provider": …}`). + case invalidProvider(rawValue: String) + /// The agent-session `renderer` token was invalid (legacy `invalid_params` / + /// "Invalid renderer (react|solid)", `data: {"renderer": …}`). + case invalidRenderer(rawValue: String) + /// The browser was disabled; carries the shared external-open outcome. + case browserDisabled(ControlSurfaceBrowserDisabledOutcome) + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// The requested/focused pane did not resolve (legacy `not_found` / "Pane not + /// found"). + case paneNotFound + /// The surface creation failed (legacy `internal_error` / "Failed to create + /// surface"). + case createFailed + /// The surface was created. Carries the echoed identity and the panel type. + case created( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID, + surfaceID: UUID, + typeRawValue: String + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCurrentSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCurrentSnapshot.swift new file mode 100644 index 000000000000..bf083621a2a7 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCurrentSnapshot.swift @@ -0,0 +1,43 @@ +public import Foundation + +/// A read-only snapshot of a workspace's current surface for the `surface.current` +/// payload. +/// +/// Mirrors the legacy `v2SurfaceCurrent` payload, with the surface/pane/type +/// optional exactly as the legacy `v2OrNull` writes (focus can be transiently nil +/// during startup, so the app falls back to the first ordered panel and may still +/// produce a nil surface). The coordinator mints all refs. +public struct ControlSurfaceCurrentSnapshot: Sendable, Equatable { + /// The enclosing window's identifier, if it resolved. + public let windowID: UUID? + /// The workspace's identifier. + public let workspaceID: UUID + /// The current surface's enclosing pane, if it resolved. + public let paneID: UUID? + /// The current surface's identifier, if any resolved. + public let surfaceID: UUID? + /// The current surface's panel-type raw value, if any resolved. + public let surfaceTypeRawValue: String? + + /// Creates a current-surface snapshot. + /// + /// - Parameters: + /// - windowID: The enclosing window's identifier, if resolved. + /// - workspaceID: The workspace's identifier. + /// - paneID: The current surface's enclosing pane, if resolved. + /// - surfaceID: The current surface's identifier, if resolved. + /// - surfaceTypeRawValue: The current surface's panel-type raw value. + public init( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID?, + surfaceID: UUID?, + surfaceTypeRawValue: String? + ) { + self.windowID = windowID + self.workspaceID = workspaceID + self.paneID = paneID + self.surfaceID = surfaceID + self.surfaceTypeRawValue = surfaceTypeRawValue + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceFocusResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceFocusResolution.swift new file mode 100644 index 000000000000..98809cbdb091 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceFocusResolution.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// The outcome of `surface.focus`, preserving the legacy body's distinct failures +/// and the echoed identity. +/// +/// The coordinator validates `surface_id` (returning `invalid_params` itself) and +/// signals `unavailable` when no seam is wired; the seam resolves the workspace, +/// focuses the window/workspace/surface, and returns this resolution. +public enum ControlSurfaceFocusResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// A TabManager resolved but no workspace did (legacy `not_found` / + /// "Workspace not found", `data: nil`). + case workspaceNotFound + /// The surface id did not match any surface in the workspace (legacy + /// `not_found` / "Surface not found", `data: {"surface_id": …}`). + case surfaceNotFound(UUID) + /// The surface was focused. Carries the echoed identity (window may be absent). + case focused(windowID: UUID?, workspaceID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthEntry.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthEntry.swift new file mode 100644 index 000000000000..d522a26df971 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthEntry.swift @@ -0,0 +1,35 @@ +public import Foundation + +/// A read-only render-health row for one surface in the `surface.health` payload. +/// +/// Mirrors the legacy per-surface dictionary the `v2SurfaceHealth` body built. The +/// `inWindow` value is optional: the legacy body wrote a Bool for terminal/browser +/// panels and `NSNull` for any other panel type, so `nil` here maps to the same +/// JSON `null`. The coordinator mints the surface ref and writes the index. +public struct ControlSurfaceHealthEntry: Sendable, Equatable { + /// The surface's panel identifier. + public let surfaceID: UUID + /// The panel type's raw value. + public let typeRawValue: String + /// Whether the surface's hosting view is in a window: a Bool for terminal + /// (`isViewInWindow`) and browser (`webView.window != nil`) panels, `nil` + /// (JSON `null`) for any other panel type. + public let inWindow: Bool? + + /// Creates a surface-health entry. + /// + /// - Parameters: + /// - surfaceID: The surface's panel identifier. + /// - typeRawValue: The panel type's raw value. + /// - inWindow: Whether the surface's hosting view is in a window, or `nil` + /// for non-terminal/browser panels. + public init( + surfaceID: UUID, + typeRawValue: String, + inWindow: Bool? + ) { + self.surfaceID = surfaceID + self.typeRawValue = typeRawValue + self.inWindow = inWindow + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthSnapshot.swift new file mode 100644 index 000000000000..841da87e8695 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthSnapshot.swift @@ -0,0 +1,32 @@ +public import Foundation + +/// A read-only render-health snapshot of a workspace's surfaces for the +/// `surface.health` payload. +/// +/// Mirrors the legacy `v2SurfaceHealth` payload: the workspace identity, the +/// ordered health rows, and the (optional) enclosing window. The coordinator mints +/// the workspace and window refs. +public struct ControlSurfaceHealthSnapshot: Sendable, Equatable { + /// The workspace's identifier. + public let workspaceID: UUID + /// The enclosing window's identifier, if it resolved. + public let windowID: UUID? + /// The ordered health rows. + public let surfaces: [ControlSurfaceHealthEntry] + + /// Creates a surface-health snapshot. + /// + /// - Parameters: + /// - workspaceID: The workspace's identifier. + /// - windowID: The enclosing window's identifier, if resolved. + /// - surfaces: The ordered health rows. + public init( + workspaceID: UUID, + windowID: UUID?, + surfaces: [ControlSurfaceHealthEntry] + ) { + self.workspaceID = workspaceID + self.windowID = windowID + self.surfaces = surfaces + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceInputStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceInputStrings.swift new file mode 100644 index 000000000000..935b47892f69 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceInputStrings.swift @@ -0,0 +1,34 @@ +public import Foundation + +/// The app-bundle-resolved localized terminal-input error strings, shared by +/// `surface.send_text` and `surface.send_key`. +/// +/// Lifted from the legacy `TerminalController` static `terminal*Message` +/// computed properties (each a `String(localized:)`). They MUST resolve in the app +/// conformance (app bundle): inside the package `String(localized:)` binds to the +/// package bundle, which lacks the keys and silently drops the Japanese +/// translation (a wire change). The app resolves them and passes them through. +public struct ControlSurfaceInputStrings: Sendable, Equatable { + /// The `input_queue_full` message. + public let inputQueueFull: String + /// The `surface_unavailable` message. + public let surfaceUnavailable: String + /// The `process_exited` message. + public let processExited: String + + /// Creates the input strings. + /// + /// - Parameters: + /// - inputQueueFull: The `input_queue_full` message. + /// - surfaceUnavailable: The `surface_unavailable` message. + /// - processExited: The `process_exited` message. + public init( + inputQueueFull: String, + surfaceUnavailable: String, + processExited: String + ) { + self.inputQueueFull = inputQueueFull + self.surfaceUnavailable = surfaceUnavailable + self.processExited = processExited + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceListSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceListSnapshot.swift new file mode 100644 index 000000000000..f4893aead2fb --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceListSnapshot.swift @@ -0,0 +1,31 @@ +public import Foundation + +/// A read-only snapshot of a workspace's surfaces for the `surface.list` payload. +/// +/// Mirrors the legacy `v2SurfaceList` payload: the workspace identity, the ordered +/// surface rows, and the (optional) enclosing window. The coordinator mints the +/// workspace and window refs and turns each ``ControlSurfaceSummary`` into a row. +public struct ControlSurfaceListSnapshot: Sendable, Equatable { + /// The workspace's identifier. + public let workspaceID: UUID + /// The enclosing window's identifier, if it resolved. + public let windowID: UUID? + /// The ordered surface rows. + public let surfaces: [ControlSurfaceSummary] + + /// Creates a surface-list snapshot. + /// + /// - Parameters: + /// - workspaceID: The workspace's identifier. + /// - windowID: The enclosing window's identifier, if resolved. + /// - surfaces: The ordered surface rows. + public init( + workspaceID: UUID, + windowID: UUID?, + surfaces: [ControlSurfaceSummary] + ) { + self.workspaceID = workspaceID + self.windowID = windowID + self.surfaces = surfaces + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfacePortsKickResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfacePortsKickResolution.swift new file mode 100644 index 000000000000..6da50540e769 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfacePortsKickResolution.swift @@ -0,0 +1,24 @@ +public import Foundation + +/// The outcome of `surface.ports_kick`, preserving the legacy body's distinct +/// failures and the kicked identity. +/// +/// The coordinator validates the params (workspace required, surface-if-present +/// must parse, reason must parse) and mints refs; every case echoes the workspace +/// id plus the requested-or-resolved surface id and the reason. The app kicks the +/// port scanner (locally or against the remote workspace) and returns this. +public enum ControlSurfacePortsKickResolution: Sendable, Equatable { + /// The workspace did not resolve (legacy `not_found` / "Workspace not found", + /// `data` echoes the workspace + requested surface). + case workspaceNotFound + /// The surface did not resolve (legacy `not_found` / "Surface not found", + /// `data` echoes the workspace + requested surface). + case surfaceNotFound + /// The workspace is a remote workspace with no surfaces yet, so the kick was + /// remembered (legacy `.ok` with `pending: true`, echoing the requested + /// surface and the reason). + case pending + /// The port scanner was kicked. Carries the resolved surface id (the legacy + /// `.ok` echoes the resolved surface, not the requested one). + case kicked(surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReadTextResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReadTextResolution.swift new file mode 100644 index 000000000000..e3da34b6dbf4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReadTextResolution.swift @@ -0,0 +1,34 @@ +public import Foundation + +/// The outcome of `surface.read_text`, preserving the legacy body's distinct +/// failures and the read text. +/// +/// The coordinator validates `lines` (`> 0`) itself; the app resolves the surface, +/// reads the Ghostty text, runs the (app-side) payload assembly, and returns this. +/// The `internalError` message is the app-side `TerminalTextPayloadError.message` +/// (or the generic "Failed to read terminal text"), carried through verbatim. +public enum ControlSurfaceReadTextResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// A `surface_id` param was present but did not parse (legacy `not_found` / + /// "Surface not found for the given surface_id"). + case surfaceNotFoundForID + /// No surface resolved and none focused (legacy `not_found` / "No focused + /// surface"). + case noFocusedSurface + /// The resolved surface is not a terminal (legacy `invalid_params` / "Surface + /// is not a terminal", `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotTerminal(UUID) + /// The read failed (legacy `internal_error`). Carries the app-side message. + case internalError(message: String) + /// The text was read. Carries the decoded text, its base64, and the identity. + case read( + text: String, + base64: String, + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRefreshResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRefreshResolution.swift new file mode 100644 index 000000000000..3ffdefaf676b --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRefreshResolution.swift @@ -0,0 +1,15 @@ +public import Foundation + +/// The outcome of `surface.refresh`, preserving the legacy body's failure and the +/// refreshed count. +/// +/// The coordinator signals `unavailable`; the app resolves the workspace, +/// force-refreshes every terminal surface, and returns the count and identity. +public enum ControlSurfaceRefreshResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// The terminals were refreshed. Carries the echoed identity and the count. + case refreshed(windowID: UUID?, workspaceID: UUID, refreshedCount: Int) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderInputs.swift new file mode 100644 index 000000000000..3df26d42d28f --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderInputs.swift @@ -0,0 +1,28 @@ +public import Foundation + +/// The pre-parsed inputs for `surface.reorder`, lifted from the legacy +/// `v2SurfaceReorder` body's param parsing. +/// +/// The coordinator validates that exactly one of `index` / `before_surface_id` / +/// `after_surface_id` is present (returning `invalid_params` itself); the app +/// resolves the surface and anchors and reorders within the pane. +public struct ControlSurfaceReorderInputs: Sendable, Equatable { + /// The explicit target `index`, or `nil`. + public let index: Int? + /// The `before_surface_id` anchor, or `nil`. + public let beforeSurfaceID: UUID? + /// The `after_surface_id` anchor, or `nil`. + public let afterSurfaceID: UUID? + + /// Creates reorder inputs. + /// + /// - Parameters: + /// - index: The explicit target index. + /// - beforeSurfaceID: The before anchor. + /// - afterSurfaceID: The after anchor. + public init(index: Int?, beforeSurfaceID: UUID?, afterSurfaceID: UUID?) { + self.index = index + self.beforeSurfaceID = beforeSurfaceID + self.afterSurfaceID = afterSurfaceID + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderResolution.swift new file mode 100644 index 000000000000..206776183a68 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderResolution.swift @@ -0,0 +1,22 @@ +public import Foundation + +/// The outcome of `surface.reorder`, preserving the legacy body's distinct +/// failures and the reordered identity. +/// +/// The coordinator validates `surface_id` and the exactly-one-target rule; the app +/// locates the surface, validates the anchors share its pane, reorders, and returns +/// this resolution. +public enum ControlSurfaceReorderResolution: Sendable, Equatable { + /// The surface did not resolve (legacy `not_found` / "Surface not found", + /// `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotFound(UUID) + /// An anchor surface was not in the same pane (legacy `invalid_params` / + /// "Anchor surface must be in the same pane"). + case anchorNotInSamePane + /// The reorder call failed (legacy `internal_error` / "Failed to reorder + /// surface"). + case reorderFailed + /// The surface was reordered. Carries the echoed identity (window and pane are + /// present from the located surface). + case reordered(windowID: UUID, workspaceID: UUID, paneID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportShellStateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportShellStateResolution.swift new file mode 100644 index 000000000000..3927e90cfaf8 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportShellStateResolution.swift @@ -0,0 +1,18 @@ +public import Foundation + +/// The outcome of `surface.report_shell_state`, preserving the legacy body's two +/// payload shapes. +/// +/// The coordinator validates the params (workspace required, surface-if-present +/// must parse, state must parse) and mints refs. The legacy body never fails after +/// validation: with an explicit surface it returns the `published` flag; without +/// one it schedules the async resolve+update and returns the pending payload. +public enum ControlSurfaceReportShellStateResolution: Sendable, Equatable { + /// An explicit surface was given; the legacy `.ok` echoes the surface, the + /// state, and the publish decision. Carries whether the activity was + /// published. + case explicit(surfaceID: UUID, published: Bool) + /// No explicit surface; the legacy `.ok` echoes a `null` surface with + /// `published: true, pending: true` (the resolve+update runs asynchronously). + case pending +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportTTYResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportTTYResolution.swift new file mode 100644 index 000000000000..93c425e8adb3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportTTYResolution.swift @@ -0,0 +1,24 @@ +public import Foundation + +/// The outcome of `surface.report_tty`, preserving the legacy body's distinct +/// failures and the recorded identity. +/// +/// The coordinator validates the params (workspace required, surface-if-present +/// must parse, tty_name required) and mints the workspace/surface refs; every case +/// echoes the workspace id plus the requested-or-resolved surface id. The app +/// records the TTY name (locally or against the remote workspace) and returns this. +public enum ControlSurfaceReportTTYResolution: Sendable, Equatable { + /// The workspace did not resolve (legacy `not_found` / "Workspace not found", + /// `data` echoes the workspace + requested surface). + case workspaceNotFound + /// The surface did not resolve (legacy `not_found` / "Surface not found", + /// `data` echoes the workspace + requested surface). + case surfaceNotFound + /// The workspace is a remote workspace with no surfaces yet, so the TTY was + /// remembered (legacy `.ok` with `pending: true`, echoing the requested + /// surface). + case pending + /// The TTY name was recorded. Carries the resolved surface id (the legacy `.ok` + /// echoes the resolved surface, not the requested one). + case recorded(surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnInputs.swift new file mode 100644 index 000000000000..9c711da4ccef --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnInputs.swift @@ -0,0 +1,49 @@ +public import Foundation + +/// The pre-parsed inputs for `surface.respawn`, lifted from the legacy +/// `v2SurfaceRespawn` body's param parsing. +/// +/// The coordinator parses these and detects the invalid-focus error itself (so the +/// localized message resolves through ``ControlSurfaceRespawnStrings``). The +/// `requestedSurfaceID` carries the explicit-`surface_id` branch: when +/// `hasSurfaceIDParam` is `true` but the id did not parse, the app returns the +/// not-found resolution (matching the legacy body). +public struct ControlSurfaceRespawnInputs: Sendable, Equatable { + /// The resume/respawn command (with the legacy + /// `exec ${SHELL:-/bin/zsh} -l` default already applied). + public let command: String + /// The tmux start command (defaults to `command`). + public let tmuxStartCommand: String + /// The trimmed-non-empty working directory, or `nil`. + public let workingDirectory: String? + /// Whether a non-null `surface_id` param was present (drives the explicit vs + /// focused-surface branch). + public let hasSurfaceIDParam: Bool + /// The resolved explicit `surface_id`, if it parsed. + public let requestedSurfaceID: UUID? + /// Whether a non-null `focus` param was present (when absent the app passes + /// `nil` focus to the respawn call, matching the legacy body). + public let hasFocusParam: Bool + /// The parsed `focus` value (used only when `hasFocusParam`); the app applies + /// the socket focus-allowance gate. + public let requestedFocus: Bool + + /// Creates respawn inputs. + public init( + command: String, + tmuxStartCommand: String, + workingDirectory: String?, + hasSurfaceIDParam: Bool, + requestedSurfaceID: UUID?, + hasFocusParam: Bool, + requestedFocus: Bool + ) { + self.command = command + self.tmuxStartCommand = tmuxStartCommand + self.workingDirectory = workingDirectory + self.hasSurfaceIDParam = hasSurfaceIDParam + self.requestedSurfaceID = requestedSurfaceID + self.hasFocusParam = hasFocusParam + self.requestedFocus = requestedFocus + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnResolution.swift new file mode 100644 index 000000000000..9474b61d58b8 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnResolution.swift @@ -0,0 +1,38 @@ +public import Foundation + +/// The outcome of `surface.respawn`, preserving the legacy body's distinct +/// (localized) failures and the respawned identity. +/// +/// The error *messages* are localized, so this enum carries only the discriminator +/// plus each case's `data` ids; the coordinator selects the matching message from +/// ``ControlSurfaceRespawnStrings`` (resolved in the app bundle) and shapes the +/// payload, keeping the wire output byte-identical including translations. +public enum ControlSurfaceRespawnResolution: Sendable, Equatable { + /// The explicit-`surface_id` branch could not resolve the surface (legacy + /// `not_found` / `surfaceNotFoundForID`). Carries the requested id for the + /// `data` (or `nil` when the id itself did not parse). + case surfaceNotFoundForID(UUID?) + /// No fallback TabManager for the focused-surface branch (legacy `unavailable` + /// / `tabManagerUnavailable`). + case tabManagerUnavailable + /// No workspace resolved on the focused branch (legacy `not_found` / + /// `workspaceNotFound`). + case workspaceNotFound + /// No focused surface on the focused branch (legacy `not_found` / + /// `noFocusedSurface`). + case noFocusedSurface + /// The resolved surface is not a terminal (legacy `invalid_params` / + /// `surfaceNotTerminal`, `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotTerminal(UUID) + /// The respawn call failed (legacy `internal_error` / `failed`, + /// `data: {"surface_id": …}`). Carries the surface id. + case respawnFailed(UUID) + /// The surface respawned. Carries the echoed identity and the resulting panel + /// type. + case respawned( + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID, + typeRawValue: String + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnStrings.swift new file mode 100644 index 000000000000..d8dd21fecdaf --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnStrings.swift @@ -0,0 +1,46 @@ +public import Foundation + +/// The app-bundle-resolved localized strings for `surface.respawn`. +/// +/// Lifted from the legacy `v2SurfaceRespawn` body's `String(localized:)` calls. +/// They MUST resolve in the app conformance (app bundle), not the package: inside +/// the package `String(localized:)` binds to the package bundle, which lacks the +/// keys and silently drops the Japanese translation (a wire change). The app +/// resolves each with the identical key + defaultValue and passes them through. +public struct ControlSurfaceRespawnStrings: Sendable, Equatable { + /// `rpc.v2.surface.respawn.invalidFocus` — "Missing or invalid focus". + public let invalidFocus: String + /// `rpc.v2.surface.respawn.failed` — "Failed to respawn surface". + public let failed: String + /// `rpc.v2.surface.respawn.surfaceNotFoundForId` — "Surface not found for the + /// given surface_id". + public let surfaceNotFoundForID: String + /// `rpc.v2.surface.respawn.tabManagerUnavailable` — "Unable to access the + /// target workspace". + public let tabManagerUnavailable: String + /// `rpc.v2.surface.respawn.workspaceNotFound` — "Workspace not found". + public let workspaceNotFound: String + /// `rpc.v2.surface.respawn.noFocusedSurface` — "No focused surface". + public let noFocusedSurface: String + /// `rpc.v2.surface.respawn.surfaceNotTerminal` — "Surface is not a terminal". + public let surfaceNotTerminal: String + + /// Creates the respawn strings. + public init( + invalidFocus: String, + failed: String, + surfaceNotFoundForID: String, + tabManagerUnavailable: String, + workspaceNotFound: String, + noFocusedSurface: String, + surfaceNotTerminal: String + ) { + self.invalidFocus = invalidFocus + self.failed = failed + self.surfaceNotFoundForID = surfaceNotFoundForID + self.tabManagerUnavailable = tabManagerUnavailable + self.workspaceNotFound = workspaceNotFound + self.noFocusedSurface = noFocusedSurface + self.surfaceNotTerminal = surfaceNotTerminal + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeBinding.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeBinding.swift new file mode 100644 index 000000000000..0801af91aea6 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeBinding.swift @@ -0,0 +1,76 @@ +public import Foundation + +/// A read-only snapshot of a surface's resume binding, as the app target exposes +/// it to ``ControlCommandCoordinator`` for the `resume_binding` payload value. +/// +/// Mirrors the legacy `v2SurfaceResumeBindingPayload` dictionary exactly (built +/// after `SurfaceResumeApprovalStore.applyingStoredApproval`). Every optional maps +/// to a legacy `v2OrNull` write. The app resolves all app-side approval state +/// (`SurfaceResumeApprovalStore`) before constructing this value; the coordinator +/// only shapes the payload. +public struct ControlSurfaceResumeBinding: Sendable, Equatable { + /// The binding's display name, if any. + public let name: String? + /// The binding's kind, if any. + public let kind: String? + /// The resume command. + public let command: String + /// The working directory, if any. + public let cwd: String? + /// The checkpoint identifier, if any. + public let checkpointID: String? + /// The binding source, if any. + public let source: String? + /// The environment overrides, if any (the legacy payload wrote the whole map + /// or `null`). + public let environment: [String: String]? + /// Whether the binding allows automatic resume + /// (`effectiveBinding.allowsAutomaticResume`). + public let autoResume: Bool + /// The approval policy's raw value, if any. + public let approvalPolicyRawValue: String? + /// The approval record identifier, if any. + public let approvalRecordID: String? + /// The last-updated timestamp (seconds since the epoch). + public let updatedAt: Double + + /// Creates a resume-binding snapshot. + /// + /// - Parameters: + /// - name: The binding's display name. + /// - kind: The binding's kind. + /// - command: The resume command. + /// - cwd: The working directory. + /// - checkpointID: The checkpoint identifier. + /// - source: The binding source. + /// - environment: The environment overrides. + /// - autoResume: Whether automatic resume is allowed. + /// - approvalPolicyRawValue: The approval policy's raw value. + /// - approvalRecordID: The approval record identifier. + /// - updatedAt: The last-updated timestamp. + public init( + name: String?, + kind: String?, + command: String, + cwd: String?, + checkpointID: String?, + source: String?, + environment: [String: String]?, + autoResume: Bool, + approvalPolicyRawValue: String?, + approvalRecordID: String?, + updatedAt: Double + ) { + self.name = name + self.kind = kind + self.command = command + self.cwd = cwd + self.checkpointID = checkpointID + self.source = source + self.environment = environment + self.autoResume = autoResume + self.approvalPolicyRawValue = approvalPolicyRawValue + self.approvalRecordID = approvalRecordID + self.updatedAt = updatedAt + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeResolution.swift new file mode 100644 index 000000000000..86f1dbc517a4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeResolution.swift @@ -0,0 +1,25 @@ +internal import Foundation + +/// The outcome of the `surface.resume.set` / `.get` / `.clear` methods, preserving +/// the legacy bodies' distinct failures and the resume result they echo back. +/// +/// The coordinator validates the routing params (returning `invalid_params` for a +/// present-but-invalid `window_id`/`workspace_id`/`surface_id`/`tab_id`) and the +/// required command for `resume.set` itself; the seam resolves the target, runs +/// the app-side approval flow, stores/reads/clears the binding, and returns this. +public enum ControlSurfaceResumeResolution: Sendable, Equatable { + /// No TabManager / window resolved (legacy `unavailable` with the shared + /// "cmux window is not available…" message). + case windowUnavailable + /// No surface target resolved (legacy `not_found` / "Surface not found"). + case surfaceNotFound + /// `resume.set` rejected an empty resume command (legacy `invalid_params` / + /// "Resume command is empty"). + case emptyResumeCommand + /// `resume.set`'s store call failed for any other reason (legacy + /// `internal_error` / "Failed to set resume binding"). + case setFailed + /// The resume result. Carries the echoed identity, the cleared flag, and the + /// resulting binding (a `null` binding still emits the `resume_binding` key). + case result(ControlSurfaceResumeSnapshot) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSetInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSetInputs.swift new file mode 100644 index 000000000000..f950529a1ba5 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSetInputs.swift @@ -0,0 +1,59 @@ +internal import Foundation + +/// The pre-parsed inputs for `surface.resume.set`, lifted from the legacy +/// `v2SurfaceResumeSet` body's param parsing. +/// +/// The coordinator parses these (the `source` already mapped through the legacy +/// `v2PublicSurfaceResumeSource` `process-detected` → `manual` rule, and +/// `autoResume` already gated to the `agent-hook` source); the app constructs the +/// app-typed `SurfaceResumeBindingSnapshot`, runs the approval flow, and stores it. +public struct ControlSurfaceResumeSetInputs: Sendable, Equatable { + /// The binding's display name (trimmed non-empty), if any. + public let name: String? + /// The binding's kind (trimmed non-empty), if any. + public let kind: String? + /// The resume command (trimmed, guaranteed non-empty by the coordinator). + public let command: String + /// The working directory (trimmed non-empty), if any. + public let cwd: String? + /// The checkpoint identifier (trimmed non-empty), if any. + public let checkpointID: String? + /// The binding source (already mapped: `process-detected` → `manual`), if any. + public let source: String? + /// The environment overrides (the legacy `v2StringMap`, or `nil`). + public let environment: [String: String]? + /// Whether automatic resume is requested (already gated: `true` only for the + /// `agent-hook` source with `auto_resume == true`). + public let autoResume: Bool + + /// Creates resume-set inputs. + /// + /// - Parameters: + /// - name: The binding's display name. + /// - kind: The binding's kind. + /// - command: The resume command. + /// - cwd: The working directory. + /// - checkpointID: The checkpoint identifier. + /// - source: The (already-mapped) binding source. + /// - environment: The environment overrides. + /// - autoResume: Whether automatic resume is requested. + public init( + name: String?, + kind: String?, + command: String, + cwd: String?, + checkpointID: String?, + source: String?, + environment: [String: String]?, + autoResume: Bool + ) { + self.name = name + self.kind = kind + self.command = command + self.cwd = cwd + self.checkpointID = checkpointID + self.source = source + self.environment = environment + self.autoResume = autoResume + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSnapshot.swift new file mode 100644 index 000000000000..c14a1bb03a2b --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSnapshot.swift @@ -0,0 +1,48 @@ +public import Foundation + +/// A read-only snapshot of a successful resume operation for the +/// `surface.resume.*` payload. +/// +/// Mirrors the legacy `v2SurfaceResumeResult` payload: the window/workspace/pane/ +/// surface identity, the `cleared` flag, and the resulting binding. The coordinator +/// mints all refs and shapes the `resume_binding` value (a `nil` binding emits JSON +/// `null` for that key, matching the legacy `v2SurfaceResumeBindingPayload(nil)`). +public struct ControlSurfaceResumeSnapshot: Sendable, Equatable { + /// The enclosing window's identifier, if it resolved. + public let windowID: UUID? + /// The workspace's identifier. + public let workspaceID: UUID + /// The surface's enclosing pane, if it resolved. + public let paneID: UUID? + /// The surface's identifier. + public let surfaceID: UUID + /// Whether the binding was cleared. + public let cleared: Bool + /// The resulting resume binding, or `nil`. + public let binding: ControlSurfaceResumeBinding? + + /// Creates a resume snapshot. + /// + /// - Parameters: + /// - windowID: The enclosing window's identifier, if resolved. + /// - workspaceID: The workspace's identifier. + /// - paneID: The surface's enclosing pane, if resolved. + /// - surfaceID: The surface's identifier. + /// - cleared: Whether the binding was cleared. + /// - binding: The resulting resume binding. + public init( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID?, + surfaceID: UUID, + cleared: Bool, + binding: ControlSurfaceResumeBinding? + ) { + self.windowID = windowID + self.workspaceID = workspaceID + self.paneID = paneID + self.surfaceID = surfaceID + self.cleared = cleared + self.binding = binding + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSendResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSendResolution.swift new file mode 100644 index 000000000000..7b5598f31c11 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSendResolution.swift @@ -0,0 +1,39 @@ +public import Foundation + +/// The outcome of `surface.send_text` / `surface.send_key`, preserving the legacy +/// bodies' distinct failures and the echoed identity. +/// +/// The two send methods share this resolution; `unknownKey` is produced only by +/// `send_key`. The terminal-input error *messages* are localized, so the +/// `inputQueueFull` / `surfaceUnavailable` / `processExited` cases carry only the +/// discriminator and the surface id; the coordinator selects the matching message +/// from ``ControlSurfaceInputStrings`` (resolved in the app bundle). +public enum ControlSurfaceSendResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// A `surface_id` param was present but did not parse (legacy `not_found` / + /// "Surface not found for the given surface_id"). + case surfaceNotFoundForID + /// No surface resolved and none focused (legacy `not_found` / "No focused + /// surface"). + case noFocusedSurface + /// The resolved surface is not a terminal (legacy `invalid_params` / "Surface + /// is not a terminal", `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotTerminal(UUID) + /// `send_key` received an unrecognized key (legacy `invalid_params` / "Unknown + /// key", `data: {"key": …}`). + case unknownKey + /// The terminal input queue is full (legacy `input_queue_full`, + /// `data: {"surface_id": …}`). Carries the surface id. + case inputQueueFull(UUID) + /// The surface is unavailable (legacy `surface_unavailable`, + /// `data: {"surface_id": …}`). Carries the surface id. + case surfaceUnavailable(UUID) + /// The process has exited (legacy `process_exited`, + /// `data: {"surface_id": …}`). Carries the surface id. + case processExited(UUID) + /// The input was sent. Carries the echoed identity and whether it was queued. + case sent(windowID: UUID?, workspaceID: UUID, surfaceID: UUID, queued: Bool) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitInputs.swift new file mode 100644 index 000000000000..d9c0e2763070 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitInputs.swift @@ -0,0 +1,61 @@ +public import Foundation + +/// The pre-parsed inputs for `surface.split`, lifted from the legacy +/// `v2SurfaceSplit` body's param parsing. +/// +/// The coordinator parses the raw tokens; the app maps `directionRaw` → +/// `SplitDirection`, `typeRaw` → `PanelType`, and `urlRaw` → `URL` (so Bonsplit / +/// PanelType / URL-availability stay app-side). The coordinator pre-validates and +/// clamps the divider, and pre-validates the agent-session rejection only as far as +/// the type token; the app rejects `agent-session` against its real `PanelType`. +public struct ControlSurfaceSplitInputs: Sendable, Equatable { + /// The raw `direction` token (validated non-nil/non-empty by the coordinator). + public let directionRaw: String + /// The raw `type` token, or `nil` (defaults to terminal). + public let typeRaw: String? + /// The raw `url` string, or `nil`. + public let urlRaw: String? + /// The requested source `surface_id`, or `nil` to split the focused surface. + public let requestedSourceSurfaceID: UUID? + /// The trimmed-non-empty `working_directory`, or `nil`. + public let workingDirectory: String? + /// The trimmed-non-empty `initial_command`, or `nil`. + public let initialCommand: String? + /// The trimmed-non-empty `tmux_start_command`, or `nil`. + public let tmuxStartCommand: String? + /// The trimmed-non-empty `remote_pty_session_id`, or `nil`. + public let remotePTYSessionID: String? + /// The startup environment (`startup_environment`/`initial_env`), `[:]` if none. + public let startupEnvironment: [String: String] + /// Whether the request asked to focus the new split. + public let requestedFocus: Bool + /// The clamped `[0.1, 0.9]` initial divider position, or `nil`. + public let initialDividerPosition: Double? + + /// Creates surface-split inputs. + public init( + directionRaw: String, + typeRaw: String?, + urlRaw: String?, + requestedSourceSurfaceID: UUID?, + workingDirectory: String?, + initialCommand: String?, + tmuxStartCommand: String?, + remotePTYSessionID: String?, + startupEnvironment: [String: String], + requestedFocus: Bool, + initialDividerPosition: Double? + ) { + self.directionRaw = directionRaw + self.typeRaw = typeRaw + self.urlRaw = urlRaw + self.requestedSourceSurfaceID = requestedSourceSurfaceID + self.workingDirectory = workingDirectory + self.initialCommand = initialCommand + self.tmuxStartCommand = tmuxStartCommand + self.remotePTYSessionID = remotePTYSessionID + self.startupEnvironment = startupEnvironment + self.requestedFocus = requestedFocus + self.initialDividerPosition = initialDividerPosition + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitResolution.swift new file mode 100644 index 000000000000..dfff32cdc6a9 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitResolution.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// The outcome of `surface.split`, preserving the legacy body's distinct failures +/// and the created identity. +/// +/// The coordinator validates `direction` and the divider (returning +/// `invalid_params` itself) and signals `unavailable`; the app maps the type token, +/// rejects `agent-session`, runs the browser-disabled path, resolves the workspace +/// and target surface, creates the split, and returns this resolution. +public enum ControlSurfaceSplitResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// The type token resolved to `agent-session` (legacy `invalid_params` / + /// "agent-session is only supported by surface.create", `data: {"type": …}`). + case agentSessionRejected(typeRawValue: String) + /// The browser was disabled; carries the shared external-open outcome. + case browserDisabled(ControlSurfaceBrowserDisabledOutcome) + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// The requested `surface_id` did not exist (legacy `not_found` / "Surface not + /// found", `data: {"surface_id": …}`). + case requestedSurfaceNotFound(UUID) + /// No focused surface to split (legacy `not_found` / "No focused surface"). + case noFocusedSurface + /// The split creation failed (legacy `internal_error` / "Failed to create + /// split"). + case createFailed + /// The split was created. Carries the echoed identity and the resulting panel + /// type (which may be `nil` if the new panel could not be re-read). + case created( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID?, + surfaceID: UUID, + typeRawValue: String? + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSummary.swift new file mode 100644 index 000000000000..8cca1476acf2 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSummary.swift @@ -0,0 +1,89 @@ +public import Foundation + +/// A read-only snapshot of one surface row for the `surface.list` payload, as the +/// app target exposes it to ``ControlCommandCoordinator``. +/// +/// Mirrors the legacy per-surface dictionary the `v2SurfaceList` body built. The +/// coordinator mints the surface and pane refs itself and writes the optional +/// fields with `null` defaults exactly as the legacy `v2OrNull` writes did. The +/// browser/terminal-only extras are carried as optionals: they are emitted only +/// when present, matching the legacy `if let` conditional key writes. +public struct ControlSurfaceSummary: Sendable, Equatable { + /// The surface's panel identifier. + public let surfaceID: UUID + /// The panel type's raw value. + public let typeRawValue: String + /// The resolved display title. + public let title: String + /// Whether this surface is the workspace's focused surface. + public let isFocused: Bool + /// The enclosing pane's identifier, if it resolved. + public let paneID: UUID? + /// The surface's index within its pane, if it resolved. + public let indexInPane: Int? + /// Whether this surface is the selected tab in its pane, if it resolved. + public let selectedInPane: Bool? + /// For browser surfaces, whether developer tools are visible (else `nil`). + public let developerToolsVisible: Bool? + /// For terminal surfaces, the requested working directory (trimmed + /// non-empty), else `nil`. Present only for terminal surfaces. + public let requestedWorkingDirectory: String? + /// For terminal surfaces, the initial command (trimmed non-empty), else + /// `nil`. Present only for terminal surfaces. + public let initialCommand: String? + /// For terminal surfaces, the tmux start command (trimmed non-empty), else + /// `nil`. Present only for terminal surfaces. + public let tmuxStartCommand: String? + /// Whether this surface is a terminal surface (drives the terminal-only key + /// emission, including the always-present `resume_binding` key). + public let isTerminal: Bool + /// For terminal surfaces, the resume binding, else `nil`. Emitted as the + /// `resume_binding` value (a `null` binding still emits the key). + public let resumeBinding: ControlSurfaceResumeBinding? + + /// Creates a surface summary. + /// + /// - Parameters: + /// - surfaceID: The surface's panel identifier. + /// - typeRawValue: The panel type's raw value. + /// - title: The resolved display title. + /// - isFocused: Whether this surface is focused. + /// - paneID: The enclosing pane's identifier, if resolved. + /// - indexInPane: The surface's index within its pane, if resolved. + /// - selectedInPane: Whether this surface is selected in its pane. + /// - developerToolsVisible: For browsers, whether dev tools are visible. + /// - requestedWorkingDirectory: For terminals, the requested working dir. + /// - initialCommand: For terminals, the initial command. + /// - tmuxStartCommand: For terminals, the tmux start command. + /// - isTerminal: Whether this is a terminal surface. + /// - resumeBinding: For terminals, the resume binding. + public init( + surfaceID: UUID, + typeRawValue: String, + title: String, + isFocused: Bool, + paneID: UUID?, + indexInPane: Int?, + selectedInPane: Bool?, + developerToolsVisible: Bool?, + requestedWorkingDirectory: String?, + initialCommand: String?, + tmuxStartCommand: String?, + isTerminal: Bool, + resumeBinding: ControlSurfaceResumeBinding? + ) { + self.surfaceID = surfaceID + self.typeRawValue = typeRawValue + self.title = title + self.isFocused = isFocused + self.paneID = paneID + self.indexInPane = indexInPane + self.selectedInPane = selectedInPane + self.developerToolsVisible = developerToolsVisible + self.requestedWorkingDirectory = requestedWorkingDirectory + self.initialCommand = initialCommand + self.tmuxStartCommand = tmuxStartCommand + self.isTerminal = isTerminal + self.resumeBinding = resumeBinding + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceTriggerFlashResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceTriggerFlashResolution.swift new file mode 100644 index 000000000000..22155b04a09b --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceTriggerFlashResolution.swift @@ -0,0 +1,22 @@ +public import Foundation + +/// The outcome of `surface.trigger_flash`, preserving the legacy body's distinct +/// failures and the echoed identity. +/// +/// The coordinator signals `unavailable`; the app resolves the workspace and +/// surface, focuses the window/workspace, triggers the focus flash, and returns +/// this resolution. +public enum ControlSurfaceTriggerFlashResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// No surface resolved and none focused (legacy `not_found` / "No focused + /// surface"). + case noFocusedSurface + /// The surface id did not exist (legacy `not_found` / "Surface not found", + /// `data: {"surface_id": …}`). Carries the surface id. + case surfaceNotFound(UUID) + /// The flash was triggered. Carries the echoed identity. + case flashed(windowID: UUID?, workspaceID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCloseResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCloseResolution.swift new file mode 100644 index 000000000000..e1ee498fd477 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCloseResolution.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// The outcome of `workspace.close`, preserving the legacy body's failures and +/// the resolved window the success/protected branches echo back. The +/// coordinator has already validated `workspace_id` shape. +public enum ControlWorkspaceCloseResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The workspace exists but is pinned and cannot be closed (legacy + /// `protected`). Carries the owning window id (may be absent); the localized + /// message is supplied via ``ControlWorkspaceStrings``. + case protected(windowID: UUID?) + /// The workspace was not in the resolved TabManager (legacy `not_found` / + /// "Workspace not found"). The legacy failure payload carries only the + /// workspace identity. + case notFound + /// The workspace was closed. Carries the owning window id (may be absent). + case resolved(windowID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceContext.swift new file mode 100644 index 000000000000..ac1089a2c838 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceContext.swift @@ -0,0 +1,283 @@ +public import Foundation + +/// The workspace-domain slice of the control-command seam (a constituent of the +/// ``ControlCommandContext`` umbrella), covering the non-group `workspace.*` +/// methods. +/// +/// The app target (today `TerminalController`, the interim composition owner) +/// conforms by resolving a `TabManager`/`Workspace` from the routing selectors +/// (the legacy `v2ResolveTabManager` precedence, or the workspace-owner-first +/// resolutions some bodies used) and reading/mutating live state. Every method +/// is `@MainActor` because its conformer and the coordinator both live on the +/// main actor, so these are plain in-isolation calls — the per-read `v2MainSync` +/// hops the legacy bodies used disappear once the domain moves onto the +/// coordinator. +/// +/// No app types (`TabManager` / `Workspace` / `AppDelegate`) cross the seam: +/// each method takes pre-parsed selectors/ids/inputs and returns Sendable +/// snapshots, resolution enums, Bools, or optionals. App-typed payloads (the +/// `remoteStatusPayload()` object) cross as bridged ``JSONValue``s. Localized +/// error messages are supplied through ``ControlWorkspaceStrings`` so they +/// resolve against the app bundle. +@MainActor +public protocol ControlWorkspaceContext: AnyObject { + /// The localized workspace error messages, resolved against the app bundle. + func controlWorkspaceStrings() -> ControlWorkspaceStrings + + /// Whether the routing selectors resolve a TabManager, used to reproduce the + /// legacy `unavailable`-first ordering for `workspace.reorder` / + /// `workspace.next` / `previous` / `last` before their param/state work. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: Whether a TabManager resolves. + func controlWorkspaceRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool + + /// Snapshots every workspace for `workspace.list`. + /// + /// - Parameter routing: The routing selectors used for TabManager + /// resolution. + /// - Returns: The list resolution. + func controlWorkspaceList(routing: ControlRoutingSelectors) -> ControlWorkspaceListResolution + + /// Snapshots the selected workspace for `workspace.current`. + /// + /// - Parameter routing: The routing selectors used for TabManager + /// resolution. + /// - Returns: The current resolution. + func controlWorkspaceCurrent(routing: ControlRoutingSelectors) -> ControlWorkspaceCurrentResolution + + /// Creates a workspace for `workspace.create`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - inputs: The pre-parsed create inputs (the app does the remaining + /// app-typed `cwd`/`layout` validation and the create). + /// - Returns: The create resolution. + func controlCreateWorkspace( + routing: ControlRoutingSelectors, + inputs: ControlWorkspaceCreateInputs + ) -> ControlWorkspaceCreateResolution + + /// Selects a workspace for `workspace.select` (focuses its window when it + /// belongs to another window). + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - workspaceID: The workspace to select. + /// - Returns: The routed resolution. + func controlSelectWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> ControlWorkspaceRoutedResolution + + /// Closes a workspace for `workspace.close`, honoring the pinned-protection + /// guard. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - workspaceID: The workspace to close. + /// - Returns: The close resolution. + func controlCloseWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> ControlWorkspaceCloseResolution + + /// Moves a workspace to another window for `workspace.move_to_window`. + /// + /// - Parameters: + /// - workspaceID: The workspace to move. + /// - windowID: The destination window. + /// - focus: Whether to focus the destination (already through the app's + /// focus-allowance gate app-side). + /// - Returns: The move resolution. + func controlMoveWorkspaceToWindow( + workspaceID: UUID, + windowID: UUID, + focusRequested: Bool + ) -> ControlWorkspaceMoveToWindowResolution + + /// Reorders a single workspace for `workspace.reorder`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - workspaceID: The workspace to move. + /// - toIndex: The absolute target index, if provided. + /// - beforeWorkspaceID: The peer to move before, if provided. + /// - afterWorkspaceID: The peer to move after, if provided. + /// - dryRun: Whether to only plan (no mutation). + /// - Returns: The reorder resolution. + func controlReorderWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID, + toIndex: Int?, + beforeWorkspaceID: UUID?, + afterWorkspaceID: UUID?, + dryRun: Bool + ) -> ControlWorkspaceReorderResolution + + /// Reorders many workspaces for `workspace.reorder_many`. + /// + /// - Parameters: + /// - routing: The routing selectors used for the special TabManager + /// resolution (explicit `window_id` wins, else the first owning + /// workspace, else the routing fallback). + /// - workspaceIDs: The desired order, already resolved from refs. + /// - dryRun: Whether to only plan (no mutation). + /// - Returns: The reorder-many resolution. + func controlReorderWorkspacesMany( + routing: ControlRoutingSelectors, + workspaceIDs: [UUID], + dryRun: Bool + ) -> ControlWorkspaceReorderManyResolution + + /// Submits a prompt for `workspace.prompt_submit`. + /// + /// - Parameters: + /// - routing: The routing selectors used for the fallback TabManager. + /// - workspaceID: The workspace to submit into (resolved owner-first). + /// - message: The selected message text, if any. + /// - Returns: The prompt-submit resolution. + func controlSubmitWorkspacePrompt( + routing: ControlRoutingSelectors, + workspaceID: UUID, + message: String? + ) -> ControlWorkspacePromptSubmitResolution + + /// Renames a workspace for `workspace.rename`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager resolution. + /// - workspaceID: The workspace to rename. + /// - title: The new (trimmed, non-empty) title. + /// - Returns: The routed resolution. + func controlRenameWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID, + title: String + ) -> ControlWorkspaceRoutedResolution + + /// Selects the next workspace for `workspace.next`. + /// + /// - Parameter routing: The routing selectors used for TabManager + /// resolution. + /// - Returns: The navigation resolution. + func controlSelectNextWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution + + /// Selects the previous workspace for `workspace.previous`. + /// + /// - Parameter routing: The routing selectors used for TabManager + /// resolution. + /// - Returns: The navigation resolution. + func controlSelectPreviousWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution + + /// Navigates to the last-visited workspace for `workspace.last`. + /// + /// - Parameter routing: The routing selectors used for TabManager + /// resolution. + /// - Returns: The navigation resolution. + func controlSelectLastWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution + + /// Equalizes splits for `workspace.equalize_splits`. + /// + /// - Parameters: + /// - routing: The routing selectors used for TabManager + workspace + /// resolution. + /// - orientationFilter: The optional `orientation` filter, trimmed + /// non-empty or `nil`. + /// - Returns: The equalize resolution. + func controlEqualizeWorkspaceSplits( + routing: ControlRoutingSelectors, + orientationFilter: String? + ) -> ControlWorkspaceEqualizeResolution + + /// Runs `workspace.remote.configure`. The body is app-typed end to end (it + /// validates ~40 params against `WorkspaceRemote*` app types and mutates the + /// workspace), so the coordinator passes the raw params and the resolved + /// workspace id through, and the app returns the fully shaped result. + /// + /// - Parameters: + /// - params: The raw command params. + /// - workspaceID: The resolved workspace id (explicit-or-selected + /// fallback already applied by the coordinator). + /// - Returns: The fully shaped call result. + func controlConfigureWorkspaceRemote( + params: [String: JSONValue], + workspaceID: UUID + ) -> ControlCallResult + + /// Disconnects a remote workspace for `workspace.remote.disconnect`. + /// + /// - Parameters: + /// - workspaceID: The resolved workspace id. + /// - clearConfiguration: Whether to clear the stored configuration. + /// - Returns: The remote resolution. + func controlDisconnectWorkspaceRemote( + workspaceID: UUID, + clearConfiguration: Bool + ) -> ControlWorkspaceRemoteResolution + + /// Reconnects a remote workspace for `workspace.remote.reconnect`. + /// + /// - Parameter workspaceID: The resolved workspace id. + /// - Returns: The remote resolution (may signal `notConfigured`). + func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution + + /// Notifies foreground-auth readiness for + /// `workspace.remote.foreground_auth_ready`. + /// + /// - Parameters: + /// - workspaceID: The resolved workspace id. + /// - foregroundAuthToken: The trimmed token, if any. + /// - Returns: The remote resolution. + func controlWorkspaceRemoteForegroundAuthReady( + workspaceID: UUID, + foregroundAuthToken: String? + ) -> ControlWorkspaceRemoteResolution + + /// Reads remote status for `workspace.remote.status`. + /// + /// - Parameter workspaceID: The resolved workspace id. + /// - Returns: The remote resolution. + func controlWorkspaceRemoteStatus(workspaceID: UUID) -> ControlWorkspaceRemoteResolution + + /// Resolves the workspace id for the remote methods that fall back to the + /// routed selected workspace when no explicit `workspace_id` was given, + /// mirroring `requestedWorkspaceId ?? fallbackTabManager?.selectedTabId`. + /// + /// - Parameters: + /// - routing: The routing selectors used for the fallback TabManager. + /// - requestedWorkspaceID: The explicit workspace id, if any. + /// - Returns: The resolved workspace id, or `nil` (legacy "Missing + /// workspace_id"). + func controlResolveRemoteWorkspaceID( + routing: ControlRoutingSelectors, + requestedWorkspaceID: UUID? + ) -> UUID? + + /// Records a remote PTY attach-end for `workspace.remote.pty_attach_end`. + /// + /// - Parameters: + /// - workspaceID: The requested workspace id. + /// - surfaceID: The surface id. + /// - sessionID: The (non-empty) session id. + /// - Returns: The attach-end resolution. + func controlWorkspaceRemotePTYAttachEnd( + workspaceID: UUID, + surfaceID: UUID, + sessionID: String + ) -> ControlWorkspaceRemotePTYAttachEndResolution + + /// Records a remote terminal session-end for + /// `workspace.remote.terminal_session_end`. + /// + /// - Parameters: + /// - workspaceID: The workspace id. + /// - surfaceID: The surface id. + /// - relayPort: The validated relay port. + /// - Returns: The session-end resolution. + func controlWorkspaceRemoteTerminalSessionEnd( + workspaceID: UUID, + surfaceID: UUID, + relayPort: Int + ) -> ControlWorkspaceRemoteTerminalSessionEndResolution +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateInputs.swift new file mode 100644 index 000000000000..f9305249be86 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateInputs.swift @@ -0,0 +1,67 @@ +public import Foundation + +/// The pre-parsed inputs for `workspace.create`, carried across +/// ``ControlWorkspaceContext`` so the seam runs the app-typed remainder (the +/// `cwd`-type / `layout` decode validation, the `addWorkspace` call) on live +/// state. +/// +/// The coordinator parses every scalar/string/map param exactly as the legacy +/// body did, but defers the `cwd` string-type check and the `layout` JSON +/// decode to the conformance because both require app types +/// (`JSONSerialization` shape / `CmuxLayoutNode`). The raw `cwd` and `layout` +/// values are passed through as ``JSONValue`` so the app can reproduce the +/// identical `invalid_params` failures and the `NSNull`/missing distinction. +public struct ControlWorkspaceCreateInputs: Sendable, Equatable { + /// The resolved title, or `nil` (legacy: trimmed, empty → nil). + public let title: String? + /// The resolved description (untrimmed raw string), or `nil`. + public let description: String? + /// The resolved `working_directory` override, or `nil` (trimmed, empty → + /// nil). When present it wins over `cwd`. + public let workingDirectory: String? + /// The raw `cwd` param value, if the key was present (may be non-string, + /// which the app rejects). Absent key → `nil`. + public let rawCWD: JSONValue? + /// The resolved `initial_command`, or `nil`. + public let initialCommand: String? + /// The resolved `initial_env` map (trimmed keys, empties dropped). + public let initialEnv: [String: String] + /// The raw `layout` param value, if present (decoded app-side). Absent → + /// `nil`. + public let rawLayout: JSONValue? + /// The requested `focus` flag, defaulted to `false` (the app runs it through + /// its `v2FocusAllowed` gate, which also drives the `eagerLoadTerminal` + /// default). + public let focusRequested: Bool + /// The parsed `eager_load_terminal` override, or `nil` when absent (legacy + /// default `!shouldFocus`, computed app-side). + public let eagerLoadTerminal: Bool? + /// The parsed `auto_refresh_metadata` override, or `nil` when absent (legacy + /// default `true`). + public let autoRefreshMetadata: Bool? + + /// Creates the create inputs. + public init( + title: String?, + description: String?, + workingDirectory: String?, + rawCWD: JSONValue?, + initialCommand: String?, + initialEnv: [String: String], + rawLayout: JSONValue?, + focusRequested: Bool, + eagerLoadTerminal: Bool?, + autoRefreshMetadata: Bool? + ) { + self.title = title + self.description = description + self.workingDirectory = workingDirectory + self.rawCWD = rawCWD + self.initialCommand = initialCommand + self.initialEnv = initialEnv + self.rawLayout = rawLayout + self.focusRequested = focusRequested + self.eagerLoadTerminal = eagerLoadTerminal + self.autoRefreshMetadata = autoRefreshMetadata + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateResolution.swift new file mode 100644 index 000000000000..0b10e5bd7188 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateResolution.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The outcome of `workspace.create`, preserving the legacy body's failure modes +/// and the resolved window/workspace/surface the success echoes back. +public enum ControlWorkspaceCreateResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// A param was malformed (legacy `invalid_params`). Carries the exact + /// message the app produced (`"cwd must be a string"`, `"layout must be a + /// valid JSON object"`, or `"Invalid layout: …"`). + case invalidParams(message: String) + /// Workspace creation failed (legacy `internal_error` / "Failed to create + /// workspace"). + case creationFailed + /// The workspace was created. Carries the owning window id (may be absent), + /// the new workspace id, and the initial surface id (may be absent). + case resolved( + windowID: UUID?, + workspaceID: UUID, + initialSurfaceID: UUID? + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCurrentResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCurrentResolution.swift new file mode 100644 index 000000000000..f290bfa127a3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCurrentResolution.swift @@ -0,0 +1,21 @@ +public import Foundation + +/// The outcome of `workspace.current`, preserving the legacy body's two distinct +/// failures and the resolved window/workspace the success echoes back. +public enum ControlWorkspaceCurrentResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// A TabManager resolved but had no selected workspace (legacy `not_found` / + /// "No workspace selected"). + case noWorkspaceSelected + /// The selected workspace was snapshotted. Carries the owning window id (may + /// be absent), the selected workspace's id, its index within the list (if + /// resolvable), and its summary. + case resolved( + windowID: UUID?, + workspaceID: UUID, + index: Int?, + summary: ControlWorkspaceSummary + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceEqualizeResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceEqualizeResolution.swift new file mode 100644 index 000000000000..42dc6b5e7bd4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceEqualizeResolution.swift @@ -0,0 +1,15 @@ +public import Foundation + +/// The outcome of `workspace.equalize_splits`, preserving the legacy body's +/// failures and the success echo. +public enum ControlWorkspaceEqualizeResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// No workspace resolved from the routing selectors (legacy `not_found` / + /// "Workspace not found"). + case notFound + /// The splits were equalized. Carries the resolved workspace id and whether + /// the tree fully equalized. + case resolved(workspaceID: UUID, equalized: Bool) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceListResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceListResolution.swift new file mode 100644 index 000000000000..727b15f3ebfb --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceListResolution.swift @@ -0,0 +1,22 @@ +public import Foundation + +/// The outcome of `workspace.list`, preserving the legacy body's single failure +/// and the resolved window/workspaces the success echoes back. +/// +/// The legacy body resolved a TabManager from the routing params, snapshotted +/// every workspace (in order, marking the selected one), then resolved the +/// owning window id (which may be absent). The coordinator mints the +/// window/workspace refs and writes the per-row `index` / `selected`. +public enum ControlWorkspaceListResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The workspaces were snapshotted. Carries the owning window id (may be + /// absent, the legacy `v2OrNull` case), the workspace snapshots in order, + /// and the index of the selected workspace within that list, if any. + case resolved( + windowID: UUID?, + workspaces: [ControlWorkspaceSummary], + selectedIndex: Int? + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceMoveToWindowResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceMoveToWindowResolution.swift new file mode 100644 index 000000000000..eb96be9c2a8f --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceMoveToWindowResolution.swift @@ -0,0 +1,16 @@ +public import Foundation + +/// The outcome of `workspace.move_to_window`, preserving the legacy body's three +/// distinct failures and the success echo. The coordinator has already +/// validated the `workspace_id` / `window_id` shapes. +public enum ControlWorkspaceMoveToWindowResolution: Sendable, Equatable { + /// The source workspace was not found (legacy `not_found` / "Workspace not + /// found", data carries only `workspace_id`). Covers both the missing + /// source TabManager and the failed detach. + case workspaceNotFound + /// The destination window was not found (legacy `not_found` / "Window not + /// found", data carries only `window_id`). + case windowNotFound + /// The workspace was moved (legacy success echoing workspace + window). + case resolved +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceNavigationResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceNavigationResolution.swift new file mode 100644 index 000000000000..2105cb9afb00 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceNavigationResolution.swift @@ -0,0 +1,20 @@ +public import Foundation + +/// The outcome of `workspace.next` / `workspace.previous` / `workspace.last`, +/// after the coordinator has confirmed routing resolves a TabManager. +/// +/// All three legacy bodies share this shape: focus the window, navigate, and on +/// success echo the now-selected workspace + window. The per-method `not_found` +/// message (`"No workspace selected"` vs `"No previous workspace in history"`) +/// is supplied by the coordinator, so the seam only signals which outcome +/// occurred. +public enum ControlWorkspaceNavigationResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// Navigation produced no new selection (legacy `not_found`). + case notFound + /// Navigation selected a workspace. Carries the now-selected workspace id and + /// the owning window id (may be absent). + case resolved(workspaceID: UUID, windowID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspacePromptSubmitResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspacePromptSubmitResolution.swift new file mode 100644 index 000000000000..285eabe0d8cc --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspacePromptSubmitResolution.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The outcome of `workspace.prompt_submit`, after the coordinator has validated +/// `workspace_id` and the message-param types and selected the message text. +public enum ControlWorkspacePromptSubmitResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The workspace was not found (legacy `not_found` / "Workspace not found", + /// data carries only `workspace_id`). + case notFound + /// The prompt was submitted. Carries the owning window id (may be absent), + /// whether iMessage mode is enabled, the submit outcome, and the latest + /// submitted message preview (may be absent). + case resolved( + windowID: UUID?, + iMessageModeEnabled: Bool, + messageRecorded: Bool, + reordered: Bool, + index: Int, + messagePreview: String? + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemotePTYAttachEndResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemotePTYAttachEndResolution.swift new file mode 100644 index 000000000000..2b7ced7cc0ec --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemotePTYAttachEndResolution.swift @@ -0,0 +1,24 @@ +public import Foundation + +/// The outcome of `workspace.remote.pty_attach_end`, after the coordinator has +/// validated `workspace_id` / `surface_id` / `session_id`. +/// +/// Both outcomes encode as a success envelope: when the workspace is not found +/// the legacy body returns its `workspace_found: false` default `ok` payload, +/// so the coordinator shapes that from `notFound`. +public enum ControlWorkspaceRemotePTYAttachEndResolution: Sendable, Equatable { + /// The workspace/surface was not located (legacy `workspace_found: false` + /// default ok payload). + case notFound + /// The attach end was recorded. Carries the owning window id (may be + /// absent), the resolved workspace id (the located owner's workspace, which + /// may differ from the requested id), the cleared/untracked flags, and the + /// bridged `remoteStatusPayload()`. + case resolved( + windowID: UUID?, + workspaceID: UUID, + clearedRemotePTYSession: Bool, + untrackedRemoteTerminal: Bool, + remoteStatus: JSONValue + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteResolution.swift new file mode 100644 index 000000000000..1974e88fb79d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteResolution.swift @@ -0,0 +1,28 @@ +public import Foundation + +/// The outcome of a workspace remote mutation that resolves a workspace by id +/// (with a selected-workspace fallback), acts on it, and echoes its remote +/// status. Shared by `workspace.remote.configure` / `disconnect` / `reconnect` +/// / `foreground_auth_ready` / `status`. +/// +/// The coordinator has already validated the `workspace_id` shape and the +/// per-method inputs; it also resolves the workspace id (a present-but-null +/// fallback to the routed selected workspace) before calling the seam, so +/// `missingWorkspaceID` here is the legacy "Missing workspace_id" +/// `invalid_params` after the fallback also yielded nothing. +public enum ControlWorkspaceRemoteResolution: Sendable, Equatable { + /// Neither an explicit nor a fallback workspace id resolved (legacy + /// `invalid_params` / "Missing workspace_id"). + case missingWorkspaceID + /// The workspace was not found (legacy `not_found` / "Workspace not found", + /// data carries the workspace identity). Carries the resolved workspace id + /// for that payload. + case notFound(workspaceID: UUID) + /// The remote workspace is not configured (legacy `invalid_state` / + /// "Remote workspace is not configured", `reconnect` only). Carries the + /// resolved workspace id for that payload. + case notConfigured(workspaceID: UUID) + /// The mutation succeeded. Carries the owning window id (may be absent), the + /// resolved workspace id, and the bridged `remoteStatusPayload()`. + case resolved(windowID: UUID?, workspaceID: UUID, remoteStatus: JSONValue) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteTerminalSessionEndResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteTerminalSessionEndResolution.swift new file mode 100644 index 000000000000..9e57f7e5c30e --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteTerminalSessionEndResolution.swift @@ -0,0 +1,13 @@ +public import Foundation + +/// The outcome of `workspace.remote.terminal_session_end`, after the +/// coordinator has validated `workspace_id` / `surface_id` / `relay_port`. +public enum ControlWorkspaceRemoteTerminalSessionEndResolution: Sendable, Equatable { + /// The workspace was not found (legacy `not_found` / "Workspace not found", + /// data carries workspace + surface + relay_port). + case notFound + /// The session end was recorded. Carries the owning window id (may be + /// absent), the resolved workspace id, and the bridged + /// `remoteStatusPayload()`. + case resolved(windowID: UUID?, workspaceID: UUID, remoteStatus: JSONValue) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderManyResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderManyResolution.swift new file mode 100644 index 000000000000..2a546b87b83c --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderManyResolution.swift @@ -0,0 +1,21 @@ +public import Foundation + +/// The outcome of `workspace.reorder_many`, after the coordinator has parsed and +/// resolved the ordered workspace ids. Preserves the legacy body's failures and +/// the success plan list. The localized messages are supplied via +/// ``ControlWorkspaceStrings`` so they resolve against the app bundle. +public enum ControlWorkspaceReorderManyResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable`, localized + /// `reorderMany.tabManagerUnavailable`). + case tabManagerUnavailable + /// A workspace appeared more than once in the order (legacy `invalid_params`, + /// localized `reorderMany.duplicateWorkspace`, data carries the workspace + /// identity). + case duplicateWorkspace(UUID) + /// A workspace in the order was not found (legacy `not_found`, localized + /// `reorderMany.workspaceNotFound`, data carries the workspace identity). + case workspaceNotFound(UUID) + /// The reorder planned (and applied unless dry-run). Carries the owning + /// window id (may be absent) and the per-workspace plan items. + case resolved(windowID: UUID?, plans: [ControlWorkspaceReorderPlanItem]) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderPlanItem.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderPlanItem.swift new file mode 100644 index 000000000000..624a2e5bcf2e --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderPlanItem.swift @@ -0,0 +1,27 @@ +public import Foundation + +/// One workspace's move in a reorder plan, mirroring the app's +/// `WorkspaceReorderPlanItem` for `workspace.reorder` / `workspace.reorder_many`. +/// +/// The coordinator turns each item into the legacy +/// `v2WorkspaceReorderPlanPayload` row, minting the workspace/window refs. +public struct ControlWorkspaceReorderPlanItem: Sendable, Equatable { + /// The workspace being moved. + public let workspaceID: UUID + /// The workspace's index before the move. + public let fromIndex: Int + /// The workspace's index after the move. + public let toIndex: Int + + /// Creates a reorder plan item. + /// + /// - Parameters: + /// - workspaceID: The workspace being moved. + /// - fromIndex: The index before the move. + /// - toIndex: The index after the move. + public init(workspaceID: UUID, fromIndex: Int, toIndex: Int) { + self.workspaceID = workspaceID + self.fromIndex = fromIndex + self.toIndex = toIndex + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderResolution.swift new file mode 100644 index 000000000000..edc4ffed7bbf --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderResolution.swift @@ -0,0 +1,12 @@ +public import Foundation + +/// The outcome of `workspace.reorder`, after the coordinator has confirmed a +/// TabManager resolves and validated the `workspace_id` / single-target params. +public enum ControlWorkspaceReorderResolution: Sendable, Equatable { + /// No plan could be built for the workspace (legacy `not_found` / "Workspace + /// not found", data carries only `workspace_id`). + case notFound + /// A plan was built (and applied unless dry-run). Carries the owning window + /// id (may be absent) and the single plan item. + case resolved(windowID: UUID?, plan: ControlWorkspaceReorderPlanItem) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRoutedResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRoutedResolution.swift new file mode 100644 index 000000000000..cb4a133e4d48 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRoutedResolution.swift @@ -0,0 +1,17 @@ +public import Foundation + +/// A routed workspace mutation outcome shared by `workspace.select` and +/// `workspace.rename`: resolve a TabManager, act on a workspace by id, echo the +/// owning window on success. Both legacy bodies share this exact failure +/// triple, and both `not_found` payloads carry only the workspace identity the +/// coordinator already holds. +public enum ControlWorkspaceRoutedResolution: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not + /// available"). + case tabManagerUnavailable + /// The workspace was not in the resolved TabManager (legacy `not_found` / + /// "Workspace not found"). + case notFound + /// The mutation succeeded. Carries the owning window id (may be absent). + case resolved(windowID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceStrings.swift new file mode 100644 index 000000000000..b0e076fa14e4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceStrings.swift @@ -0,0 +1,49 @@ +internal import Foundation + +/// The localized workspace-domain error messages, resolved against the app +/// bundle so ``ControlCommandCoordinator`` can shape the localized error +/// envelopes without binding `String(localized:)` to the package bundle (which +/// lacks the keys, silently dropping non-English translations = a wire change). +/// +/// The notification / workspace-group domains use the same pattern. Each field +/// carries the exact `String(localized:)` result the legacy body produced. +public struct ControlWorkspaceStrings: Sendable, Equatable { + /// `workspace.closeProtected.message` — the `workspace.close` protected-pin + /// error. + public let closeProtected: String + /// `socket.workspace.reorderMany.missingOrder`. + public let reorderManyMissingOrder: String + /// `socket.workspace.reorderMany.duplicateWorkspace`. + public let reorderManyDuplicateWorkspace: String + /// `socket.workspace.reorderMany.workspaceNotFound`. + public let reorderManyWorkspaceNotFound: String + /// `socket.workspace.reorderMany.invalidWorkspace`. + public let reorderManyInvalidWorkspace: String + /// `socket.workspace.reorderMany.tabManagerUnavailable`. + public let reorderManyTabManagerUnavailable: String + + /// Creates the localized workspace strings. + /// + /// - Parameters: + /// - closeProtected: The `workspace.close` protected-pin message. + /// - reorderManyMissingOrder: The missing-order message. + /// - reorderManyDuplicateWorkspace: The duplicate-workspace message. + /// - reorderManyWorkspaceNotFound: The workspace-not-found message. + /// - reorderManyInvalidWorkspace: The invalid-workspace message. + /// - reorderManyTabManagerUnavailable: The TabManager-unavailable message. + public init( + closeProtected: String, + reorderManyMissingOrder: String, + reorderManyDuplicateWorkspace: String, + reorderManyWorkspaceNotFound: String, + reorderManyInvalidWorkspace: String, + reorderManyTabManagerUnavailable: String + ) { + self.closeProtected = closeProtected + self.reorderManyMissingOrder = reorderManyMissingOrder + self.reorderManyDuplicateWorkspace = reorderManyDuplicateWorkspace + self.reorderManyWorkspaceNotFound = reorderManyWorkspaceNotFound + self.reorderManyInvalidWorkspace = reorderManyInvalidWorkspace + self.reorderManyTabManagerUnavailable = reorderManyTabManagerUnavailable + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceSummary.swift new file mode 100644 index 000000000000..03e53297068e --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceSummary.swift @@ -0,0 +1,74 @@ +public import Foundation + +/// A read-only snapshot of one workspace, as the app target exposes it to +/// ``ControlCommandCoordinator`` through ``ControlWorkspaceContext``. +/// +/// Mirrors the fields the legacy `v2WorkspaceSummaryPayload` read off a live +/// `Workspace`, with the app-typed `remoteStatusPayload()` already bridged to a +/// ``JSONValue`` so no app types cross the seam. The coordinator turns each +/// summary into the `workspace.list` / `workspace.current` payload row, +/// minting the workspace ref and writing the `selected` / `index` keys it owns. +public struct ControlWorkspaceSummary: Sendable, Equatable { + /// The workspace's stable identifier. + public let id: UUID + /// The workspace's display title. + public let title: String + /// The user-set description, if any (the legacy `v2OrNull` field). + public let customDescription: String? + /// Whether the workspace is pinned. + public let isPinned: Bool + /// The workspace's currently-listening ports, in app order. + public let listeningPorts: [Int] + /// The bridged `remoteStatusPayload()` object. + public let remoteStatus: JSONValue + /// The workspace's current working directory, if any. + public let currentDirectory: String? + /// The user-set custom color, if any. + public let customColor: String? + /// The latest conversation message, if any. + public let latestConversationMessage: String? + /// The latest submitted message, if any. + public let latestSubmittedMessage: String? + /// The latest submitted timestamp (already ISO-formatted), if any. + public let latestSubmittedAt: String? + + /// Creates a workspace summary. + /// + /// - Parameters: + /// - id: The workspace's stable identifier. + /// - title: The display title. + /// - customDescription: The user-set description, if any. + /// - isPinned: Whether the workspace is pinned. + /// - listeningPorts: The listening ports. + /// - remoteStatus: The bridged `remoteStatusPayload()` object. + /// - currentDirectory: The current working directory, if any. + /// - customColor: The custom color, if any. + /// - latestConversationMessage: The latest conversation message, if any. + /// - latestSubmittedMessage: The latest submitted message, if any. + /// - latestSubmittedAt: The ISO-formatted latest submitted timestamp, if any. + public init( + id: UUID, + title: String, + customDescription: String?, + isPinned: Bool, + listeningPorts: [Int], + remoteStatus: JSONValue, + currentDirectory: String?, + customColor: String?, + latestConversationMessage: String?, + latestSubmittedMessage: String?, + latestSubmittedAt: String? + ) { + self.id = id + self.title = title + self.customDescription = customDescription + self.isPinned = isPinned + self.listeningPorts = listeningPorts + self.remoteStatus = remoteStatus + self.currentDirectory = currentDirectory + self.customColor = customColor + self.latestConversationMessage = latestConversationMessage + self.latestSubmittedMessage = latestSubmittedMessage + self.latestSubmittedAt = latestSubmittedAt + } +} diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index 3d63d50614cc..fa5aeb7fa190 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -165,6 +165,267 @@ extension ControlWorkspaceGroupContext { ) -> ControlWorkspaceGroupFocusResolution { .tabManagerUnavailable } } +extension ControlWorkspaceContext { + func controlWorkspaceStrings() -> ControlWorkspaceStrings { + ControlWorkspaceStrings( + closeProtected: "", + reorderManyMissingOrder: "", + reorderManyDuplicateWorkspace: "", + reorderManyWorkspaceNotFound: "", + reorderManyInvalidWorkspace: "", + reorderManyTabManagerUnavailable: "" + ) + } + + func controlWorkspaceRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool { false } + + func controlWorkspaceList(routing: ControlRoutingSelectors) -> ControlWorkspaceListResolution { + .tabManagerUnavailable + } + + func controlWorkspaceCurrent(routing: ControlRoutingSelectors) -> ControlWorkspaceCurrentResolution { + .tabManagerUnavailable + } + + func controlCreateWorkspace( + routing: ControlRoutingSelectors, + inputs: ControlWorkspaceCreateInputs + ) -> ControlWorkspaceCreateResolution { .tabManagerUnavailable } + + func controlSelectWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> ControlWorkspaceRoutedResolution { .tabManagerUnavailable } + + func controlCloseWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> ControlWorkspaceCloseResolution { .tabManagerUnavailable } + + func controlMoveWorkspaceToWindow( + workspaceID: UUID, + windowID: UUID, + focusRequested: Bool + ) -> ControlWorkspaceMoveToWindowResolution { .workspaceNotFound } + + func controlReorderWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID, + toIndex: Int?, + beforeWorkspaceID: UUID?, + afterWorkspaceID: UUID?, + dryRun: Bool + ) -> ControlWorkspaceReorderResolution { .notFound } + + func controlReorderWorkspacesMany( + routing: ControlRoutingSelectors, + workspaceIDs: [UUID], + dryRun: Bool + ) -> ControlWorkspaceReorderManyResolution { .tabManagerUnavailable } + + func controlSubmitWorkspacePrompt( + routing: ControlRoutingSelectors, + workspaceID: UUID, + message: String? + ) -> ControlWorkspacePromptSubmitResolution { .tabManagerUnavailable } + + func controlRenameWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID, + title: String + ) -> ControlWorkspaceRoutedResolution { .tabManagerUnavailable } + + func controlSelectNextWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution { + .tabManagerUnavailable + } + + func controlSelectPreviousWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution { + .tabManagerUnavailable + } + + func controlSelectLastWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution { + .tabManagerUnavailable + } + + func controlEqualizeWorkspaceSplits( + routing: ControlRoutingSelectors, + orientationFilter: String? + ) -> ControlWorkspaceEqualizeResolution { .tabManagerUnavailable } + + func controlConfigureWorkspaceRemote( + params: [String: JSONValue], + workspaceID: UUID + ) -> ControlCallResult { .err(code: "unavailable", message: "", data: nil) } + + func controlDisconnectWorkspaceRemote( + workspaceID: UUID, + clearConfiguration: Bool + ) -> ControlWorkspaceRemoteResolution { .notFound(workspaceID: workspaceID) } + + func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution { + .notFound(workspaceID: workspaceID) + } + + func controlWorkspaceRemoteForegroundAuthReady( + workspaceID: UUID, + foregroundAuthToken: String? + ) -> ControlWorkspaceRemoteResolution { .notFound(workspaceID: workspaceID) } + + func controlWorkspaceRemoteStatus(workspaceID: UUID) -> ControlWorkspaceRemoteResolution { + .notFound(workspaceID: workspaceID) + } + + func controlResolveRemoteWorkspaceID( + routing: ControlRoutingSelectors, + requestedWorkspaceID: UUID? + ) -> UUID? { requestedWorkspaceID } + + func controlWorkspaceRemotePTYAttachEnd( + workspaceID: UUID, + surfaceID: UUID, + sessionID: String + ) -> ControlWorkspaceRemotePTYAttachEndResolution { .notFound } + + func controlWorkspaceRemoteTerminalSessionEnd( + workspaceID: UUID, + surfaceID: UUID, + relayPort: Int + ) -> ControlWorkspaceRemoteTerminalSessionEndResolution { .notFound } +} + +extension ControlSurfaceContext { + func controlSurfaceRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool { false } + + func controlSurfaceList(routing: ControlRoutingSelectors) -> ControlSurfaceListSnapshot? { nil } + func controlSurfaceCurrent(routing: ControlRoutingSelectors) -> ControlSurfaceCurrentSnapshot? { nil } + func controlSurfaceHealth(routing: ControlRoutingSelectors) -> ControlSurfaceHealthSnapshot? { nil } + + func controlSurfaceFocus( + routing: ControlRoutingSelectors, + surfaceID: UUID + ) -> ControlSurfaceFocusResolution { .tabManagerUnavailable } + + func controlSurfaceRespawnStrings() -> ControlSurfaceRespawnStrings { + ControlSurfaceRespawnStrings( + invalidFocus: "", + failed: "", + surfaceNotFoundForID: "", + tabManagerUnavailable: "", + workspaceNotFound: "", + noFocusedSurface: "", + surfaceNotTerminal: "" + ) + } + + func controlSurfaceSplit( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceSplitInputs + ) -> ControlSurfaceSplitResolution { .tabManagerUnavailable } + + func controlSurfaceRespawn( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceRespawnInputs + ) -> ControlSurfaceRespawnResolution { .tabManagerUnavailable } + + func controlSurfaceCreate( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceCreateInputs + ) -> ControlSurfaceCreateResolution { .tabManagerUnavailable } + + func controlSurfaceClose( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceCloseResolution { .tabManagerUnavailable } + + func controlSurfaceMove(params: [String: JSONValue]) -> ControlCallResult { + .err(code: "internal_error", message: "", data: nil) + } + + func controlSurfaceReorder( + surfaceID: UUID, + inputs: ControlSurfaceReorderInputs, + requestedFocus: Bool + ) -> ControlSurfaceReorderResolution { .surfaceNotFound(surfaceID) } + + func controlSurfaceRefresh( + routing: ControlRoutingSelectors + ) -> ControlSurfaceRefreshResolution { .tabManagerUnavailable } + + func controlSurfaceClearHistory( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceClearHistoryResolution { .tabManagerUnavailable } + + func controlSurfaceTriggerFlash( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceTriggerFlashResolution { .tabManagerUnavailable } + + func controlSurfaceInputStrings() -> ControlSurfaceInputStrings { + ControlSurfaceInputStrings(inputQueueFull: "", surfaceUnavailable: "", processExited: "") + } + + func controlSurfaceSendText( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + text: String + ) -> ControlSurfaceSendResolution { .tabManagerUnavailable } + + func controlSurfaceSendKey( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + key: String + ) -> ControlSurfaceSendResolution { .tabManagerUnavailable } + + func controlSurfaceReadText( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + includeScrollback: Bool, + lineLimit: Int? + ) -> ControlSurfaceReadTextResolution { .tabManagerUnavailable } + + func controlSurfaceResumeSet( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceResumeSetInputs + ) -> ControlSurfaceResumeResolution { .surfaceNotFound } + + func controlSurfaceResumeGet( + routing: ControlRoutingSelectors + ) -> ControlSurfaceResumeResolution { .surfaceNotFound } + + func controlSurfaceResumeClear( + routing: ControlRoutingSelectors, + expectedCheckpointID: String?, + expectedSource: String? + ) -> ControlSurfaceResumeResolution { .surfaceNotFound } + + func controlSurfaceParseShellActivityState(_ rawState: String) -> String? { nil } + func controlSurfaceParsePortScanKickReason(_ rawReason: String) -> String? { nil } + + func controlSurfaceReportTTY( + workspaceID: UUID, + requestedSurfaceID: UUID?, + ttyName: String + ) -> ControlSurfaceReportTTYResolution { .workspaceNotFound } + + func controlSurfaceReportShellState( + workspaceID: UUID, + requestedSurfaceID: UUID?, + stateRawValue: String + ) -> ControlSurfaceReportShellStateResolution { .pending } + + func controlSurfacePortsKick( + workspaceID: UUID, + requestedSurfaceID: UUID?, + reasonRawValue: String + ) -> ControlSurfacePortsKickResolution { .workspaceNotFound } + + func controlDebugTerminals() -> JSONValue? { nil } +} + extension ControlMobileHostContext { private var mobileHostStubResult: ControlCallResult { .err(code: "unavailable", message: "", data: nil) diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift index f75a16aab3b7..b49e416d1b04 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorWindowTests.swift @@ -68,7 +68,8 @@ struct ControlCommandCoordinatorWindowTests { @Test func unownedMethodFallsThrough() { let (coordinator, _) = makeCoordinator() - #expect(coordinator.handle(request("workspace.list")) == nil) + // A method no coordinator domain owns yet (browser is not extracted). + #expect(coordinator.handle(request("browser.navigate")) == nil) } @Test func windowListBuildsRowsWithMintedRefs() { diff --git a/Sources/TerminalController+ControlSurfaceContext.swift b/Sources/TerminalController+ControlSurfaceContext.swift new file mode 100644 index 000000000000..15480966d457 --- /dev/null +++ b/Sources/TerminalController+ControlSurfaceContext.swift @@ -0,0 +1,200 @@ +import AppKit +import Bonsplit +import CmuxControlSocket +import Foundation +import GhosttyKit + +/// The surface-domain witnesses are the byte-faithful bodies of the former +/// `v2Surface*` / `v2DebugTerminals` dispatchers, minus the per-read `v2MainSync` +/// hop: the coordinator already runs on the main actor inside the socket-command +/// policy scope, so each hop would re-apply the identical thread-local +/// focus-allowance stack — a no-op. +/// +/// App-coupled resolution (`resolveTabManager(routing:)`, `v2ResolveWindowId`, the +/// Bonsplit layout, surface creation/move, the Ghostty reads, the resume approval +/// flow, the `debug.terminals` table) stays here; the seam exposes only Sendable +/// snapshots, resolution enums, and one bridged ``JSONValue`` (`debug.terminals`). +/// Every blocking `NSAlert` and `String(localized:)` resolves here, in the app +/// bundle, so translations survive. +extension TerminalController: ControlSurfaceContext { + func controlSurfaceRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool { + resolveTabManager(routing: routing) != nil + } + + /// The routing twin of the legacy `v2ResolveWorkspace(params:tabManager:)`. + /// `internal` (not `private`) so the surface witnesses in the sibling + /// `+ControlSurfaceContext2`/`3` files share it. + func resolveSurfaceWorkspace( + routing: ControlRoutingSelectors, + tabManager: TabManager + ) -> Workspace? { + if let wsId = routing.workspaceID { + return tabManager.tabs.first(where: { $0.id == wsId }) + } + if let surfaceId = routing.surfaceID { + return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) + } + if let paneId = routing.paneID, let located = v2LocatePane(paneId) { + guard located.tabManager === tabManager else { return nil } + return located.workspace + } + guard let wsId = tabManager.selectedTabId else { return nil } + return tabManager.tabs.first(where: { $0.id == wsId }) + } + + /// Converts an app resume-binding snapshot (after `applyingStoredApproval`) into + /// the seam value type, byte-faithful to `v2SurfaceResumeBindingPayload`. + /// `internal` (not `private`) so the resume witnesses in the sibling + /// `+ControlSurfaceContext3` file share it. + func controlResumeBinding( + from binding: SurfaceResumeBindingSnapshot? + ) -> ControlSurfaceResumeBinding? { + guard let binding else { return nil } + let effective = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) + return ControlSurfaceResumeBinding( + name: effective.name, + kind: effective.kind, + command: effective.command, + cwd: effective.cwd, + checkpointID: effective.checkpointId, + source: effective.source, + environment: effective.environment, + autoResume: effective.allowsAutomaticResume, + approvalPolicyRawValue: effective.approvalPolicy?.rawValue, + approvalRecordID: effective.approvalRecordId, + updatedAt: effective.updatedAt + ) + } + + // MARK: - list + + func controlSurfaceList(routing: ControlRoutingSelectors) -> ControlSurfaceListSnapshot? { + guard let tabManager = resolveTabManager(routing: routing), + let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return nil + } + + 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 surfaces: [ControlSurfaceSummary] = orderedPanels(in: ws).map { panel in + let terminalPanel = panel as? TerminalPanel + return ControlSurfaceSummary( + surfaceID: panel.id, + typeRawValue: panel.panelType.rawValue, + title: ws.panelTitle(panelId: panel.id) ?? panel.displayTitle, + isFocused: panel.id == focusedSurfaceId, + paneID: paneByPanelId[panel.id], + indexInPane: indexInPaneByPanelId[panel.id], + selectedInPane: selectedInPaneByPanelId[panel.id], + developerToolsVisible: (panel as? BrowserPanel)?.isDeveloperToolsVisible(), + requestedWorkingDirectory: terminalPanel.flatMap { + v2NonEmptyString($0.requestedWorkingDirectory) + }, + initialCommand: terminalPanel.flatMap { + v2NonEmptyString($0.surface.debugInitialCommand()) + }, + tmuxStartCommand: terminalPanel.flatMap { + v2NonEmptyString($0.surface.debugTmuxStartCommand()) + }, + isTerminal: terminalPanel != nil, + resumeBinding: terminalPanel != nil + ? controlResumeBinding(from: ws.surfaceResumeBinding(panelId: panel.id)) + : nil + ) + } + + return ControlSurfaceListSnapshot( + workspaceID: ws.id, + windowID: v2ResolveWindowId(tabManager: tabManager), + surfaces: surfaces + ) + } + + // MARK: - current + + func controlSurfaceCurrent(routing: ControlRoutingSelectors) -> ControlSurfaceCurrentSnapshot? { + guard let tabManager = resolveTabManager(routing: routing), + let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return nil + } + let surfaceId = ws.focusedPanelId ?? orderedPanels(in: ws).first?.id + let paneId = surfaceId.flatMap { ws.paneId(forPanelId: $0)?.id } + return ControlSurfaceCurrentSnapshot( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + paneID: paneId, + surfaceID: surfaceId, + surfaceTypeRawValue: surfaceId.flatMap { ws.panels[$0]?.panelType.rawValue } + ) + } + + // MARK: - health + + func controlSurfaceHealth(routing: ControlRoutingSelectors) -> ControlSurfaceHealthSnapshot? { + guard let tabManager = resolveTabManager(routing: routing), + let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return nil + } + let items: [ControlSurfaceHealthEntry] = orderedPanels(in: ws).map { panel in + var inWindow: Bool? + if let tp = panel as? TerminalPanel { + inWindow = tp.surface.isViewInWindow + } else if let bp = panel as? BrowserPanel { + inWindow = bp.webView.window != nil + } + return ControlSurfaceHealthEntry( + surfaceID: panel.id, + typeRawValue: panel.panelType.rawValue, + inWindow: inWindow + ) + } + return ControlSurfaceHealthSnapshot( + workspaceID: ws.id, + windowID: v2ResolveWindowId(tabManager: tabManager), + surfaces: items + ) + } + + // MARK: - focus + + func controlSurfaceFocus( + routing: ControlRoutingSelectors, + surfaceID: UUID + ) -> ControlSurfaceFocusResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + if let windowId = v2ResolveWindowId(tabManager: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + if tabManager.selectedTabId != ws.id { + tabManager.selectWorkspace(ws) + } + guard ws.panels[surfaceID] != nil else { + return .surfaceNotFound(surfaceID) + } + ws.focusPanel(surfaceID) + return .focused( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceID + ) + } +} diff --git a/Sources/TerminalController+ControlSurfaceContext2.swift b/Sources/TerminalController+ControlSurfaceContext2.swift new file mode 100644 index 000000000000..087da9a7ce3b --- /dev/null +++ b/Sources/TerminalController+ControlSurfaceContext2.swift @@ -0,0 +1,378 @@ +import AppKit +import Bonsplit +import CmuxControlSocket +import Foundation + +/// The surface-domain lifecycle witnesses (`split` / `respawn` / `create` / +/// `close` / `move` / `reorder`) plus the browser-disabled mapping and the +/// localized respawn strings. Split out of `TerminalController+ControlSurfaceContext` +/// to keep the conformance readable; see that file's doc comment for the overview. +extension TerminalController { + func controlSurfaceRespawnStrings() -> ControlSurfaceRespawnStrings { + ControlSurfaceRespawnStrings( + invalidFocus: String( + localized: "rpc.v2.surface.respawn.invalidFocus", + defaultValue: "Missing or invalid focus" + ), + failed: String( + localized: "rpc.v2.surface.respawn.failed", + defaultValue: "Failed to respawn surface" + ), + surfaceNotFoundForID: String( + localized: "rpc.v2.surface.respawn.surfaceNotFoundForId", + defaultValue: "Surface not found for the given surface_id" + ), + tabManagerUnavailable: String( + localized: "rpc.v2.surface.respawn.tabManagerUnavailable", + defaultValue: "Unable to access the target workspace" + ), + workspaceNotFound: String( + localized: "rpc.v2.surface.respawn.workspaceNotFound", + defaultValue: "Workspace not found" + ), + noFocusedSurface: String( + localized: "rpc.v2.surface.respawn.noFocusedSurface", + defaultValue: "No focused surface" + ), + surfaceNotTerminal: String( + localized: "rpc.v2.surface.respawn.surfaceNotTerminal", + defaultValue: "Surface is not a terminal" + ) + ) + } + + /// The byte-faithful twin of `v2BrowserDisabledExternalOpenResult`, mapped onto + /// the shared ``ControlSurfaceBrowserDisabledOutcome``. + private func surfaceBrowserDisabledOutcome( + rawURL: String?, + url: URL?, + tabManager: TabManager? + ) -> ControlSurfaceBrowserDisabledOutcome { + if let rawURL, url == nil { + return .invalidURL(rawURL: rawURL) + } + guard let url else { + return .noURL + } + guard NSWorkspace.shared.open(url) else { + return .externalOpenFailed(url: url.absoluteString) + } + let windowId = v2ResolveWindowId(tabManager: tabManager) + return .openedExternally(windowID: windowId, url: url.absoluteString) + } + + // MARK: - split + + func controlSurfaceSplit( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceSplitInputs + ) -> ControlSurfaceSplitResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + // Direction validated by the coordinator; the app maps it to SplitDirection. + guard let direction = parseSplitDirection(inputs.directionRaw) else { + // Unreachable: the coordinator pre-validates direction is non-empty, but + // an unrecognized token still maps to the same legacy invalid_params. + return .tabManagerUnavailable + } + let panelType = inputs.typeRaw.flatMap { surfacePanelType(forRawToken: $0) } ?? .terminal + if panelType == .agentSession { + return .agentSessionRejected(typeRawValue: panelType.rawValue) + } + let url = inputs.urlRaw.flatMap { URL(string: $0) } + if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { + return .browserDisabled(surfaceBrowserDisabledOutcome( + rawURL: inputs.urlRaw, + url: url, + tabManager: tabManager + )) + } + + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + let targetSurfaceId: UUID? + if let requested = inputs.requestedSourceSurfaceID { + guard ws.panels[requested] != nil else { + return .requestedSurfaceNotFound(requested) + } + targetSurfaceId = requested + } else { + targetSurfaceId = ws.focusedPanelId + } + guard let targetSurfaceId, ws.panels[targetSurfaceId] != nil else { + return .noFocusedSurface + } + + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + let focus = v2FocusAllowed(requested: inputs.requestedFocus) + let orientation = direction.orientation + let insertFirst = direction.insertFirst + let dividerPosition = inputs.initialDividerPosition.map { CGFloat($0) } + let newId: UUID? + if panelType == .browser { + newId = ws.newBrowserSplit( + from: targetSurfaceId, + orientation: orientation, + insertFirst: insertFirst, + url: url, + focus: focus, + creationPolicy: .automationPreload, + initialDividerPosition: dividerPosition + )?.id + } else { + newId = tabManager.newSplit( + tabId: ws.id, + surfaceId: targetSurfaceId, + direction: direction, + focus: focus, + workingDirectory: inputs.workingDirectory, + initialCommand: inputs.initialCommand, + tmuxStartCommand: inputs.tmuxStartCommand, + startupEnvironment: inputs.startupEnvironment, + initialDividerPosition: dividerPosition, + remotePTYSessionID: inputs.remotePTYSessionID + ) + } + + guard let newId else { + return .createFailed + } + return .created( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + paneID: ws.paneId(forPanelId: newId)?.id, + surfaceID: newId, + typeRawValue: ws.panels[newId]?.panelType.rawValue + ) + } + + // MARK: - respawn + + func controlSurfaceRespawn( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceRespawnInputs + ) -> ControlSurfaceRespawnResolution { + let fallbackTabManager = resolveTabManager(routing: routing) + + let ws: Workspace + let tabManager: TabManager + let surfaceId: UUID + if inputs.hasSurfaceIDParam { + guard let requestedSurfaceId = inputs.requestedSurfaceID else { + return .surfaceNotFoundForID(nil) + } + guard let located = AppDelegate.shared?.locateSurface(surfaceId: requestedSurfaceId), + let locatedWorkspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }) else { + return .surfaceNotFoundForID(requestedSurfaceId) + } + ws = locatedWorkspace + tabManager = located.tabManager + surfaceId = requestedSurfaceId + } else { + guard let fallbackTabManager else { + return .tabManagerUnavailable + } + guard let resolvedWorkspace = resolveSurfaceWorkspace( + routing: routing, + tabManager: fallbackTabManager + ) else { + return .workspaceNotFound + } + guard let focusedSurfaceId = resolvedWorkspace.focusedPanelId else { + return .noFocusedSurface + } + ws = resolvedWorkspace + tabManager = fallbackTabManager + surfaceId = focusedSurfaceId + } + guard ws.terminalPanel(for: surfaceId) != nil else { + return .surfaceNotTerminal(surfaceId) + } + + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + let focus: Bool? = inputs.hasFocusParam + ? v2FocusAllowed(requested: inputs.requestedFocus) + : nil + guard let replacementPanel = ws.respawnTerminalSurface( + panelId: surfaceId, + command: inputs.command, + workingDirectory: inputs.workingDirectory, + tmuxStartCommand: inputs.tmuxStartCommand, + focus: focus + ) else { + return .respawnFailed(surfaceId) + } + return .respawned( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceId, + typeRawValue: replacementPanel.panelType.rawValue + ) + } + + // MARK: - create + + func controlSurfaceCreate( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceCreateInputs + ) -> ControlSurfaceCreateResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + let panelType = inputs.typeRaw.flatMap { surfacePanelType(forRawToken: $0) } ?? .terminal + + var providerID: AgentSessionProviderID = .codex + var rendererKind: AgentSessionRendererKind = .react + if panelType == .agentSession { + if let providerRaw = inputs.providerRaw { + switch v2NormalizedToken(providerRaw) { + case "codex": providerID = .codex + case "claude", "claudecode": providerID = .claude + case "opencode": providerID = .opencode + default: return .invalidProvider(rawValue: providerRaw) + } + } + if let rendererRaw = inputs.rendererRaw { + switch v2NormalizedToken(rendererRaw) { + case "react": rendererKind = .react + case "solid": rendererKind = .solid + default: return .invalidRenderer(rawValue: rendererRaw) + } + } + } + + let url = inputs.urlRaw.flatMap { URL(string: $0) } + if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { + return .browserDisabled(surfaceBrowserDisabledOutcome( + rawURL: inputs.urlRaw, + url: url, + tabManager: tabManager + )) + } + + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + + let paneId: PaneID? = { + if let paneUUID = inputs.requestedPaneID { + return ws.bonsplitController.allPaneIds.first(where: { $0.id == paneUUID }) + } + return ws.bonsplitController.focusedPaneId + }() + guard let paneId else { + return .paneNotFound + } + + let focus = v2FocusAllowed(requested: inputs.requestedFocus) + let newPanelId: UUID? + if panelType == .browser { + newPanelId = ws.newBrowserSurface( + inPane: paneId, + url: url, + focus: focus, + creationPolicy: .automationPreload + )?.id + } else if panelType == .agentSession { + newPanelId = ws.newAgentSessionSurface( + inPane: paneId, + providerID: providerID, + rendererKind: rendererKind, + workingDirectory: inputs.workingDirectory, + focus: focus + )?.id + } else { + newPanelId = ws.newTerminalSurface( + inPane: paneId, + focus: focus, + workingDirectory: inputs.workingDirectory, + initialCommand: inputs.initialCommand, + tmuxStartCommand: inputs.tmuxStartCommand, + startupEnvironment: inputs.startupEnvironment, + remotePTYSessionID: inputs.remotePTYSessionID + )?.id + } + + guard let newPanelId else { + return .createFailed + } + return .created( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + paneID: paneId.id, + surfaceID: newPanelId, + typeRawValue: panelType.rawValue + ) + } + + // MARK: - close + + func controlSurfaceClose( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceCloseResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + guard let surfaceId = surfaceID ?? ws.focusedPanelId else { + return .noFocusedSurface + } + guard ws.panels[surfaceId] != nil else { + return .surfaceNotFound(surfaceId) + } + if ws.panels.count <= 1 { + return .lastSurface + } + // Socket API must be non-interactive: bypass close-confirmation gating. + guard controlCloseSurfaceRecordingHistory(in: ws, surfaceId: surfaceId, force: true) else { + return .closeFailed(surfaceId) + } + return .closed( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceId + ) + } + + /// The byte-faithful twin of the file-private `closeSurfaceRecordingHistory`, + /// re-declared here because `private` is file-scoped and the original lives in + /// `TerminalController.swift`. + @discardableResult + private func controlCloseSurfaceRecordingHistory( + in workspace: Workspace, + surfaceId: UUID, + force: Bool + ) -> Bool { + if let tabId = workspace.surfaceIdFromPanelId(surfaceId) { + return workspace.requestCloseTabRecordingHistory(tabId, force: force) + } + workspace.markCloseHistoryEligible(panelId: surfaceId) + return workspace.closePanel(surfaceId, force: force) + } + + /// The byte-faithful twin of `v2PanelType`'s token mapping (the `v2PanelType` + /// helper takes `[String: Any]`; the coordinator passes the raw token, so this + /// maps a token directly, identical to the legacy switch). + private func surfacePanelType(forRawToken raw: String) -> PanelType? { + switch v2NormalizedToken(raw) { + case "terminal": return .terminal + case "browser": return .browser + case "markdown": return .markdown + case "filepreview": return .filePreview + case "rightsidebartool": return .rightSidebarTool + case "agentsession": return .agentSession + default: return nil + } + } +} diff --git a/Sources/TerminalController+ControlSurfaceContext3.swift b/Sources/TerminalController+ControlSurfaceContext3.swift new file mode 100644 index 000000000000..fa8b7e4dd89a --- /dev/null +++ b/Sources/TerminalController+ControlSurfaceContext3.swift @@ -0,0 +1,352 @@ +import AppKit +import Bonsplit +import CmuxControlSocket +import Foundation + +/// The surface-domain input / read / resume / reporting witnesses, plus the +/// `surface.move` bridge and `debug.terminals` passthrough. Split out of +/// `TerminalController+ControlSurfaceContext` to keep the conformance readable; see +/// that file's doc comment for the overview. +extension TerminalController { + + // MARK: - move (bridge to still-app-side v2SurfaceMove) + + func controlSurfaceMove(params: [String: JSONValue]) -> ControlCallResult { + // `v2SurfaceMove` walks windows/workspaces/panes and mutates Bonsplit; it + // stays in TerminalController.swift (shared with pane.join). We forward the + // raw params and bridge its Foundation result, exactly as pane.join does. + let foundationParams = params.mapValues(\.foundationObject) + switch v2SurfaceMove(params: foundationParams) { + case let .ok(payload): + return .ok(JSONValue(foundationObject: payload) ?? .object([:])) + case let .err(code, message, data): + return .err(code: code, message: message, data: data.flatMap { JSONValue(foundationObject: $0) }) + } + } + + // MARK: - reorder + + func controlSurfaceReorder( + surfaceID: UUID, + inputs: ControlSurfaceReorderInputs, + requestedFocus: Bool + ) -> ControlSurfaceReorderResolution { + let focus = v2FocusAllowed(requested: requestedFocus) + 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 { + return .surfaceNotFound(surfaceID) + } + + let targetIndex: Int + if let index = inputs.index { + targetIndex = index + } else if let beforeSurfaceID = inputs.beforeSurfaceID { + guard let anchorPane = ws.paneId(forPanelId: beforeSurfaceID), + anchorPane == sourcePane, + let anchorIndex = ws.indexInPane(forPanelId: beforeSurfaceID) else { + return .anchorNotInSamePane + } + targetIndex = anchorIndex + } else if let afterSurfaceID = inputs.afterSurfaceID { + guard let anchorPane = ws.paneId(forPanelId: afterSurfaceID), + anchorPane == sourcePane, + let anchorIndex = ws.indexInPane(forPanelId: afterSurfaceID) else { + return .anchorNotInSamePane + } + targetIndex = anchorIndex + 1 + } else { + // Unreachable: the coordinator enforces exactly-one-target. + return .reorderFailed + } + + guard ws.reorderSurface(panelId: surfaceID, toIndex: targetIndex, focus: focus) else { + return .reorderFailed + } + return .reordered( + windowID: located.windowId, + workspaceID: ws.id, + paneID: sourcePane.id, + surfaceID: surfaceID + ) + } + + // MARK: - refresh + + func controlSurfaceRefresh(routing: ControlRoutingSelectors) -> ControlSurfaceRefreshResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + var refreshedCount = 0 + for panel in ws.panels.values { + if let terminalPanel = panel as? TerminalPanel { + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceRefresh") + refreshedCount += 1 + } + } + return .refreshed( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + refreshedCount: refreshedCount + ) + } + + // MARK: - clear_history + + func controlSurfaceClearHistory( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceClearHistoryResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + guard let surfaceId = surfaceID ?? ws.focusedPanelId else { + return .noFocusedSurface + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + return .surfaceNotTerminal(surfaceId) + } + guard terminalPanel.performBindingAction("clear_screen") else { + return .bindingActionUnavailable + } + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceClearHistory") + return .cleared( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceId + ) + } + + // MARK: - trigger_flash + + func controlSurfaceTriggerFlash( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlSurfaceTriggerFlashResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + guard let surfaceId = surfaceID ?? ws.focusedPanelId else { + return .noFocusedSurface + } + guard ws.panels[surfaceId] != nil else { + return .surfaceNotFound(surfaceId) + } + v2MaybeFocusWindow(for: tabManager) + v2MaybeSelectWorkspace(tabManager, workspace: ws) + ws.triggerFocusFlash(panelId: surfaceId) + return .flashed( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceId + ) + } + + // MARK: - send_text / send_key + + func controlSurfaceInputStrings() -> ControlSurfaceInputStrings { + ControlSurfaceInputStrings( + inputQueueFull: String( + localized: "socket.terminal.inputQueueFull", + defaultValue: "The terminal can't accept more input right now. Wait a moment and retry, or reopen the terminal if it stays unavailable." + ), + surfaceUnavailable: String( + localized: "socket.terminal.surfaceUnavailable", + defaultValue: "The terminal surface is no longer available; reopen it or create a new terminal session." + ), + processExited: String( + localized: "socket.terminal.processExited", + defaultValue: "The terminal session has ended; reopen it or create a new terminal session." + ) + ) + } + + /// Resolves the send target surface, matching the legacy + /// `params["surface_id"] != nil` branch (an explicit param that did not parse + /// signals `surfaceNotFoundForID`; otherwise the focused surface). + /// The send-target resolution outcome (a domain value, not an `Error`, so it + /// is not a `Result.Failure`). + private enum SendSurfaceTarget { + case surface(UUID) + case unresolved(ControlSurfaceSendResolution) + } + + private func resolveSendSurface( + in ws: Workspace, + surfaceID: UUID?, + hasSurfaceIDParam: Bool + ) -> SendSurfaceTarget { + if hasSurfaceIDParam { + guard let surfaceId = surfaceID else { + return .unresolved(.surfaceNotFoundForID) + } + return .surface(surfaceId) + } + guard let focused = ws.focusedPanelId else { + return .unresolved(.noFocusedSurface) + } + return .surface(focused) + } + + func controlSurfaceSendText( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + text: String + ) -> ControlSurfaceSendResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + let surfaceId: UUID + switch resolveSendSurface(in: ws, surfaceID: surfaceID, hasSurfaceIDParam: hasSurfaceIDParam) { + case .unresolved(let resolution): return resolution + case .surface(let id): surfaceId = id + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + return .surfaceNotTerminal(surfaceId) + } + let queued: Bool + switch terminalPanel.sendInputResult(text) { + case .sent: + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendText") + queued = false + case .queued: + queued = true + case .inputQueueFull: + return .inputQueueFull(surfaceId) + case .surfaceUnavailable: + return .surfaceUnavailable(surfaceId) + case .processExited: + return .processExited(surfaceId) + } + return .sent( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceId, + queued: queued + ) + } + + func controlSurfaceSendKey( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + key: String + ) -> ControlSurfaceSendResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + let surfaceId: UUID + switch resolveSendSurface(in: ws, surfaceID: surfaceID, hasSurfaceIDParam: hasSurfaceIDParam) { + case .unresolved(let resolution): return resolution + case .surface(let id): surfaceId = id + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + return .surfaceNotTerminal(surfaceId) + } + let sendResult = terminalPanel.sendNamedKeyResult(key) + switch sendResult { + case .sent: + terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") + case .queued: + break + case .unknownKey: + return .unknownKey + case .inputQueueFull: + return .inputQueueFull(surfaceId) + case .surfaceUnavailable: + return .surfaceUnavailable(surfaceId) + case .processExited: + return .processExited(surfaceId) + } + return .sent( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceId, + queued: sendResult == .queued + ) + } + + // MARK: - read_text + + func controlSurfaceReadText( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + includeScrollback: Bool, + lineLimit: Int? + ) -> ControlSurfaceReadTextResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + let surfaceId: UUID + if hasSurfaceIDParam { + guard let id = surfaceID else { return .surfaceNotFoundForID } + surfaceId = id + } else { + guard let focused = ws.focusedPanelId else { return .noFocusedSurface } + surfaceId = focused + } + guard let terminalPanel = ws.terminalPanel(for: surfaceId) else { + return .surfaceNotTerminal(surfaceId) + } + + guard let rawSnapshot = readTerminalTextRawSnapshot( + terminalPanel: terminalPanel, + includeScrollback: includeScrollback + ) else { + return .internalError(message: "Failed to read terminal text") + } + switch Self.terminalTextPayload( + from: rawSnapshot, + includeScrollback: includeScrollback, + lineLimit: lineLimit + ) { + case .success(let payload): + return .read( + text: payload.text, + base64: payload.base64, + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: ws.id, + surfaceID: surfaceId + ) + case .failure(let error): + return .internalError(message: error.message) + } + } + + // MARK: - debug.terminals + + func controlDebugTerminals() -> JSONValue? { + // The legacy `v2DebugTerminals` builds a dozens-of-fields `[String: Any]` + // from NSWindow/NSView/Ghostty internals. It is the single irreducibly + // app-coupled payload in this domain, so we keep the body app-side and + // bridge its Foundation dictionary to a JSONValue (the documented + // single-method passthrough). `v2DebugTerminals` ignores its params. + switch v2DebugTerminals(params: [:]) { + case let .ok(payload): + return JSONValue(foundationObject: payload) + case .err: + return nil + } + } +} diff --git a/Sources/TerminalController+ControlSurfaceContext4.swift b/Sources/TerminalController+ControlSurfaceContext4.swift new file mode 100644 index 000000000000..96899a5ffd32 --- /dev/null +++ b/Sources/TerminalController+ControlSurfaceContext4.swift @@ -0,0 +1,412 @@ +import AppKit +import Bonsplit +import CmuxControlSocket +import Foundation + +/// The surface-domain resume (`resume.set` / `.get` / `.clear`) and reporting +/// (`report_tty` / `report_shell_state` / `ports_kick`) witnesses, plus the token +/// parsers. Split out of `TerminalController+ControlSurfaceContext` to keep the +/// conformance readable; see that file's doc comment for the overview. The blocking +/// approval `NSAlert` and its `String(localized:)` calls resolve here, in the app +/// bundle, so translations survive. +extension TerminalController { + + // MARK: - resume target resolution (twin of v2ResolveSurfaceResumeTarget) + + /// The byte-faithful twin of the file-private `v2ResolveSurfaceResumeTarget`, + /// re-declared here because `private` is file-scoped. It uses the routing + /// selectors the coordinator already parsed in place of the raw params. + private func resolveSurfaceResumeTarget( + routing: ControlRoutingSelectors, + fallbackTabManager: TabManager + ) -> (tabManager: TabManager, workspace: Workspace, surfaceId: UUID)? { + if let explicitSurfaceId = routing.surfaceID { + if let explicitWorkspaceId = routing.workspaceID { + guard let workspace = fallbackTabManager.tabs.first(where: { $0.id == explicitWorkspaceId }), + workspace.terminalPanel(for: explicitSurfaceId) != nil else { + return nil + } + return (fallbackTabManager, workspace, explicitSurfaceId) + } + if routing.hasWindowIDParam { + guard let workspace = fallbackTabManager.tabs.first(where: { + $0.terminalPanel(for: explicitSurfaceId) != nil + }) else { + return nil + } + return (fallbackTabManager, workspace, explicitSurfaceId) + } + if let located = AppDelegate.shared?.locateSurface(surfaceId: explicitSurfaceId), + let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), + workspace.terminalPanel(for: explicitSurfaceId) != nil { + return (located.tabManager, workspace, explicitSurfaceId) + } + if let workspace = fallbackTabManager.tabs.first(where: { + $0.terminalPanel(for: explicitSurfaceId) != nil + }) { + return (fallbackTabManager, workspace, explicitSurfaceId) + } + if let workspace = resolveSurfaceWorkspace(routing: routing, tabManager: fallbackTabManager), + workspace.terminalPanel(for: explicitSurfaceId) != nil { + return (fallbackTabManager, workspace, explicitSurfaceId) + } + return nil + } + guard let workspace = resolveSurfaceWorkspace(routing: routing, tabManager: fallbackTabManager), + let surfaceId = workspace.focusedPanelId, + workspace.terminalPanel(for: surfaceId) != nil else { + return nil + } + return (fallbackTabManager, workspace, surfaceId) + } + + /// Builds the resume snapshot the seam returns, mirroring `v2SurfaceResumeResult`. + private func surfaceResumeSnapshot( + tabManager: TabManager, + workspace: Workspace, + surfaceId: UUID, + binding: SurfaceResumeBindingSnapshot?, + cleared: Bool + ) -> ControlSurfaceResumeSnapshot { + ControlSurfaceResumeSnapshot( + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: workspace.id, + paneID: workspace.paneId(forPanelId: surfaceId)?.id, + surfaceID: surfaceId, + cleared: cleared, + binding: controlResumeBinding(from: binding) + ) + } + + // MARK: - resume approval flow (twin of v2SurfaceResumeBindingWithApproval) + + /// The byte-faithful twin of the file-private + /// `v2SurfaceResumeBindingWithApproval`, re-declared here. Runs the blocking + /// approval prompt in the app bundle. + private func surfaceResumeBindingWithApproval( + _ binding: SurfaceResumeBindingSnapshot + ) -> SurfaceResumeBindingSnapshot { + let existingRecord = SurfaceResumeApprovalStore.matchingRecord(for: binding) + var effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) + if let promptlessCLIManualBinding = SurfaceResumeApprovalStore.applyingPromptlessCLIManualApprovalIfNeeded( + to: binding, + existingRecord: existingRecord + ) { + return promptlessCLIManualBinding + } + guard SurfaceResumeApprovalStore.shouldPromptForProposal( + binding: binding, + existingRecord: existingRecord, + isMainThread: Thread.isMainThread, + isRunningTests: ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + ) else { + return effectiveBinding + } + let policy = surfacePromptForResumeApproval(binding: effectiveBinding) + guard let record = SurfaceResumeApprovalStore.approve(binding: binding, policy: policy) else { + return effectiveBinding + } + effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) + effectiveBinding.approvalPolicy = record.policy + effectiveBinding.approvalRecordId = record.id + effectiveBinding.autoResume = record.policy == .auto + return effectiveBinding + } + + /// The byte-faithful twin of the file-private `v2PromptForSurfaceResumeApproval`. + /// The blocking `NSAlert` and its `String(localized:)` calls resolve here. + private func surfacePromptForResumeApproval( + binding: SurfaceResumeBindingSnapshot + ) -> SurfaceResumeApprovalPolicy { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = String( + localized: "surfaceResumeApproval.proposal.title", + defaultValue: "Allow Resume Command?" + ) + let cwd = binding.cwd ?? String(localized: "surfaceResumeApproval.cwd.none", defaultValue: "None") + alert.informativeText = String( + format: String( + localized: "surfaceResumeApproval.proposal.message", + defaultValue: "A process wants cmux to keep this resume command for the current terminal:\n\n%@\n\nWorking directory: %@" + ), + binding.command, + cwd + ) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.auto", defaultValue: "Auto-Restore")) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.ask", defaultValue: "Ask Each Time")) + alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.manual", defaultValue: "Keep Manual")) + + switch alert.runModal() { + case .alertFirstButtonReturn: + return .auto + case .alertSecondButtonReturn: + return .prompt + default: + return .manual + } + } + + // MARK: - resume.set / get / clear + + func controlSurfaceResumeSet( + routing: ControlRoutingSelectors, + inputs: ControlSurfaceResumeSetInputs + ) -> ControlSurfaceResumeResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .windowUnavailable + } + let binding = SurfaceResumeBindingSnapshot( + name: inputs.name, + kind: inputs.kind, + command: inputs.command, + cwd: inputs.cwd, + checkpointId: inputs.checkpointID, + source: inputs.source, + environment: inputs.environment, + autoResume: inputs.autoResume, + updatedAt: Date().timeIntervalSince1970 + ) + guard let target = resolveSurfaceResumeTarget(routing: routing, fallbackTabManager: tabManager) else { + return .surfaceNotFound + } + let effectiveBinding = surfaceResumeBindingWithApproval(binding) + guard target.workspace.setSurfaceResumeBinding(effectiveBinding, panelId: target.surfaceId) else { + return .emptyResumeCommand + } + return .result(surfaceResumeSnapshot( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: effectiveBinding, + cleared: false + )) + } + + func controlSurfaceResumeGet(routing: ControlRoutingSelectors) -> ControlSurfaceResumeResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .windowUnavailable + } + guard let target = resolveSurfaceResumeTarget(routing: routing, fallbackTabManager: tabManager) else { + return .surfaceNotFound + } + return .result(surfaceResumeSnapshot( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: target.workspace.surfaceResumeBinding(panelId: target.surfaceId), + cleared: false + )) + } + + func controlSurfaceResumeClear( + routing: ControlRoutingSelectors, + expectedCheckpointID: String?, + expectedSource: String? + ) -> ControlSurfaceResumeResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .windowUnavailable + } + guard let target = resolveSurfaceResumeTarget(routing: routing, fallbackTabManager: tabManager) else { + return .surfaceNotFound + } + let currentBinding = target.workspace.surfaceResumeBinding(panelId: target.surfaceId) + if let expectedCheckpointID, currentBinding?.checkpointId != expectedCheckpointID { + return .result(surfaceResumeSnapshot( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: currentBinding, + cleared: false + )) + } + if let expectedSource, currentBinding?.source != expectedSource { + return .result(surfaceResumeSnapshot( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: currentBinding, + cleared: false + )) + } + _ = target.workspace.clearSurfaceResumeBinding(panelId: target.surfaceId) + return .result(surfaceResumeSnapshot( + tabManager: target.tabManager, + workspace: target.workspace, + surfaceId: target.surfaceId, + binding: nil, + cleared: true + )) + } + + // MARK: - token parsers + + func controlSurfaceParseShellActivityState(_ rawState: String) -> String? { + Self.parseReportedShellActivityState(rawState)?.rawValue + } + + func controlSurfaceParsePortScanKickReason(_ rawReason: String) -> String? { + Self.parseRemotePortScanKickReason(rawReason)?.rawValue + } + + // MARK: - report_tty + + func controlSurfaceReportTTY( + workspaceID: UUID, + requestedSurfaceID: UUID?, + ttyName: String + ) -> ControlSurfaceReportTTYResolution { + guard let tab = controlTabForSidebarMutation(id: workspaceID) else { + return .workspaceNotFound + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let surfaceId = controlResolveReportedSurfaceId( + in: tab, + requestedSurfaceId: requestedSurfaceID, + validSurfaceIds: validSurfaceIds + ) + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { + if tab.isRemoteWorkspace, validSurfaceIds.isEmpty { + tab.rememberPendingRemoteSurfaceTTY(ttyName, requestedSurfaceId: requestedSurfaceID) + return .pending + } + return .surfaceNotFound + } + + tab.surfaceTTYNames[surfaceId] = ttyName + if tab.isRemoteWorkspace { + tab.syncRemotePortScanTTYs() + _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId) + } else { + PortScanner.shared.registerTTY(workspaceId: workspaceID, panelId: surfaceId, ttyName: ttyName) + } + return .recorded(surfaceID: surfaceId) + } + + // MARK: - report_shell_state + + func controlSurfaceReportShellState( + workspaceID: UUID, + requestedSurfaceID: UUID?, + stateRawValue: String + ) -> ControlSurfaceReportShellStateResolution { + guard let state = Workspace.PanelShellActivityState(rawValue: stateRawValue) else { + // Unreachable: the coordinator only forwards a value the app produced. + return .pending + } + if let requestedSurfaceID { + let shouldPublish = socketFastPathState.shouldPublishShellActivity( + workspaceId: workspaceID, + panelId: requestedSurfaceID, + state: state.rawValue + ) + if shouldPublish { + DispatchQueue.main.async { + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceID) else { return } + tabManager.updateSurfaceShellActivity( + tabId: workspaceID, + surfaceId: requestedSurfaceID, + state: state + ) + } + } + return .explicit(surfaceID: requestedSurfaceID, published: shouldPublish) + } + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + guard let tab = self.controlTabForSidebarMutation(id: workspaceID) else { return } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + let surfaceId = self.controlResolveReportedSurfaceId( + in: tab, + requestedSurfaceId: requestedSurfaceID, + validSurfaceIds: validSurfaceIds + ) + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { return } + guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: tab.id) else { return } + tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state) + } + return .pending + } + + // MARK: - ports_kick + + func controlSurfacePortsKick( + workspaceID: UUID, + requestedSurfaceID: UUID?, + reasonRawValue: String + ) -> ControlSurfacePortsKickResolution { + guard let reason = WorkspaceRemoteSessionController.PortScanKickReason(rawValue: reasonRawValue) else { + // Unreachable: the coordinator only forwards a value the app produced. + return .workspaceNotFound + } + guard let tab = controlTabForSidebarMutation(id: workspaceID) else { + return .workspaceNotFound + } + let validSurfaceIds = Set(tab.panels.keys) + tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) + + let surfaceId = controlResolveReportedSurfaceId( + in: tab, + requestedSurfaceId: requestedSurfaceID, + validSurfaceIds: validSurfaceIds + ) + guard let surfaceId, validSurfaceIds.contains(surfaceId) else { + if tab.isRemoteWorkspace, validSurfaceIds.isEmpty { + tab.rememberPendingRemoteSurfacePortKick(reason: reason, requestedSurfaceId: requestedSurfaceID) + return .pending + } + return .surfaceNotFound + } + + if tab.isRemoteWorkspace { + tab.kickRemotePortScan(panelId: surfaceId, reason: reason) + } else { + PortScanner.shared.kick(workspaceId: workspaceID, panelId: surfaceId) + } + return .kicked(surfaceID: surfaceId) + } + + // MARK: - shared report helpers (twins of file-private members) + + /// The byte-faithful twin of the file-private `tabForSidebarMutation(id:)`: + /// the controller's own TabManager first, then any window's TabManager. + private func controlTabForSidebarMutation(id: UUID) -> Workspace? { + 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 + } + + /// The byte-faithful twin of the file-private `resolveReportedSurfaceId`. + private func controlResolveReportedSurfaceId( + in workspace: Workspace, + requestedSurfaceId: UUID?, + validSurfaceIds: Set + ) -> UUID? { + if let requestedSurfaceId { + guard validSurfaceIds.contains(requestedSurfaceId) else { return nil } + return requestedSurfaceId + } + if let focusedSurfaceId = workspace.focusedPanelId, + validSurfaceIds.contains(focusedSurfaceId), + (!workspace.isRemoteWorkspace || workspace.isRemoteTerminalSurface(focusedSurfaceId)) { + return focusedSurfaceId + } + guard workspace.isRemoteWorkspace else { return nil } + let remoteTerminalSurfaceIds = validSurfaceIds.filter { workspace.isRemoteTerminalSurface($0) } + if remoteTerminalSurfaceIds.count == 1 { + return remoteTerminalSurfaceIds.first + } + if validSurfaceIds.count == 1 { + return validSurfaceIds.first + } + return nil + } +} diff --git a/Sources/TerminalController+ControlWorkspaceContext.swift b/Sources/TerminalController+ControlWorkspaceContext.swift new file mode 100644 index 000000000000..12aca13e62ca --- /dev/null +++ b/Sources/TerminalController+ControlWorkspaceContext.swift @@ -0,0 +1,805 @@ +import CmuxControlSocket +import Foundation + +/// The workspace-domain witnesses for the stage-3c ``ControlCommandCoordinator``: +/// the byte-faithful bodies of the former non-group `v2Workspace*` dispatchers, +/// minus the per-read `v2MainSync` hop (the coordinator already runs on the main +/// actor inside the socket-command policy scope, so each hop would re-apply the +/// identical thread-local focus-allowance stack — a no-op). TabManager +/// resolution goes through the shared `resolveTabManager(routing:)` and the +/// workspace-owner-first resolutions the legacy bodies used; app structs are +/// converted to the package's Sendable snapshots, and app-typed payloads (the +/// `remoteStatusPayload()` object) are bridged to ``JSONValue``. +/// +/// `workspace.group.*` lives in `TerminalController+ControlWorkspaceGroupContext`; +/// `workspace.action` / `extension.sidebar.snapshot` and the worker-lane +/// `workspace.remote.pty_*` (sessions/close/detach/bridge/resize) methods stay on +/// the app-side dispatcher. +extension TerminalController: ControlWorkspaceContext { + func controlWorkspaceStrings() -> ControlWorkspaceStrings { + ControlWorkspaceStrings( + closeProtected: String( + localized: "workspace.closeProtected.message", + defaultValue: "Pinned workspaces can't be closed while pinned. Unpin the workspace first." + ), + reorderManyMissingOrder: String( + localized: "socket.workspace.reorderMany.missingOrder", + defaultValue: "Missing workspace_ids" + ), + reorderManyDuplicateWorkspace: String( + localized: "socket.workspace.reorderMany.duplicateWorkspace", + defaultValue: "Duplicate workspace in order" + ), + reorderManyWorkspaceNotFound: String( + localized: "socket.workspace.reorderMany.workspaceNotFound", + defaultValue: "Workspace not found" + ), + reorderManyInvalidWorkspace: String( + localized: "socket.workspace.reorderMany.invalidWorkspace", + defaultValue: "Invalid workspace id or ref" + ), + reorderManyTabManagerUnavailable: String( + localized: "socket.workspace.reorderMany.tabManagerUnavailable", + defaultValue: "TabManager not available" + ) + ) + } + + func controlWorkspaceRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool { + resolveTabManager(routing: routing) != nil + } + + // MARK: - Snapshots + + /// Builds the Sendable summary of one workspace (the legacy + /// `v2WorkspaceSummaryPayload` data, minus the index/selected/ref minting the + /// coordinator now owns), bridging the app-typed `remoteStatusPayload()`. + private func controlWorkspaceSummary(_ workspace: Workspace) -> ControlWorkspaceSummary { + ControlWorkspaceSummary( + id: workspace.id, + title: workspace.title, + customDescription: workspace.customDescription, + isPinned: workspace.isPinned, + listeningPorts: workspace.listeningPorts, + remoteStatus: JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]), + currentDirectory: workspace.currentDirectory, + customColor: workspace.customColor, + latestConversationMessage: workspace.latestConversationMessage, + latestSubmittedMessage: workspace.latestSubmittedMessage, + latestSubmittedAt: workspace.latestSubmittedAt.map(CmuxEventBus.isoTimestamp) + ) + } + + // MARK: - List / current + + func controlWorkspaceList(routing: ControlRoutingSelectors) -> ControlWorkspaceListResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + let selectedId = tabManager.selectedTabId + var selectedIndex: Int? + let summaries = tabManager.tabs.enumerated().map { index, ws -> ControlWorkspaceSummary in + if ws.id == selectedId { + selectedIndex = index + } + return controlWorkspaceSummary(ws) + } + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved(windowID: windowId, workspaces: summaries, selectedIndex: selectedIndex) + } + + func controlWorkspaceCurrent(routing: ControlRoutingSelectors) -> ControlWorkspaceCurrentResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let workspaceId = tabManager.selectedTabId, + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + return .noWorkspaceSelected + } + let index = tabManager.tabs.firstIndex(where: { $0.id == workspaceId }) + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved( + windowID: windowId, + workspaceID: workspaceId, + index: index, + summary: controlWorkspaceSummary(workspace) + ) + } + + // MARK: - Create + + func controlCreateWorkspace( + routing: ControlRoutingSelectors, + inputs: ControlWorkspaceCreateInputs + ) -> ControlWorkspaceCreateResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + + let cwd: String? + if let workingDirectory = inputs.workingDirectory { + cwd = workingDirectory + } else if let rawCWD = inputs.rawCWD, !rawCWD.isControlNull { + guard case .string(let str) = rawCWD else { + return .invalidParams(message: "cwd must be a string") + } + cwd = str + } else { + cwd = nil + } + + // Decode optional layout param (same JSON schema as cmux.json layout + // field). Validate before creating the workspace so malformed layouts + // fail fast. + var layoutNode: CmuxLayoutNode? + if let rawLayout = inputs.rawLayout, !rawLayout.isControlNull { + let foundationLayout = rawLayout.foundationObject + guard JSONSerialization.isValidJSONObject(foundationLayout), + let layoutData = try? JSONSerialization.data(withJSONObject: foundationLayout) else { + return .invalidParams(message: "layout must be a valid JSON object") + } + do { + layoutNode = try JSONDecoder().decode(CmuxLayoutNode.self, from: layoutData) + } catch { + return .invalidParams(message: "Invalid layout: \(error.localizedDescription)") + } + } + + let shouldFocus = v2FocusAllowed(requested: inputs.focusRequested) + let shouldEagerLoadTerminal = inputs.eagerLoadTerminal ?? !shouldFocus + let shouldAutoRefreshMetadata = inputs.autoRefreshMetadata ?? true + + let ws = tabManager.addWorkspace( + title: inputs.title, + workingDirectory: cwd, + initialTerminalCommand: layoutNode == nil ? inputs.initialCommand : nil, + initialTerminalEnvironment: layoutNode == nil ? inputs.initialEnv : [:], + select: shouldFocus, + eagerLoadTerminal: shouldEagerLoadTerminal, + autoRefreshMetadata: shouldAutoRefreshMetadata + ) + ws.setCustomDescription(inputs.description) + if let layoutNode { + ws.applyCustomLayout(layoutNode, baseCwd: cwd ?? ws.currentDirectory) + } + let newId = ws.id + let initialSurfaceId = ws.focusedPanelId + + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved(windowID: windowId, workspaceID: newId, initialSurfaceID: initialSurfaceId) + } + + // MARK: - Select / close / move + + func controlSelectWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> ControlWorkspaceRoutedResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = tabManager.tabs.first(where: { $0.id == workspaceID }) else { + return .notFound + } + // If this workspace belongs to another window, bring it forward so focus + // is visible. + let windowId = AppDelegate.shared?.windowId(for: tabManager) + if let windowId { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectWorkspace(ws) + return .resolved(windowID: windowId) + } + + func controlCloseWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID + ) -> ControlWorkspaceCloseResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + let windowId = AppDelegate.shared?.windowId(for: tabManager) + guard let ws = tabManager.tabs.first(where: { $0.id == workspaceID }) else { + return .notFound + } + guard tabManager.canCloseWorkspace(ws) else { + return .protected(windowID: windowId) + } + tabManager.closeWorkspace(ws) + return .resolved(windowID: windowId) + } + + func controlMoveWorkspaceToWindow( + workspaceID: UUID, + windowID: UUID, + focusRequested: Bool + ) -> ControlWorkspaceMoveToWindowResolution { + guard let srcTM = AppDelegate.shared?.tabManagerFor(tabId: workspaceID) else { + return .workspaceNotFound + } + guard let dstTM = AppDelegate.shared?.tabManagerFor(windowId: windowID) else { + return .windowNotFound + } + guard let ws = srcTM.detachWorkspace(tabId: workspaceID) else { + return .workspaceNotFound + } + let focus = v2FocusAllowed(requested: focusRequested) + dstTM.attachWorkspace(ws, select: focus) + if focus { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowID) + setActiveTabManager(dstTM) + } + return .resolved + } + + // MARK: - Reorder + + func controlReorderWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID, + toIndex: Int?, + beforeWorkspaceID: UUID?, + afterWorkspaceID: UUID?, + dryRun: Bool + ) -> ControlWorkspaceReorderResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + // The coordinator already confirmed routing resolves a TabManager, + // so this only fails if the window vanished between calls; treat as + // not-found to match the legacy outcome. + return .notFound + } + let plan: WorkspaceReorderPlanItem? + if let toIndex { + plan = tabManager.workspaceReorderPlan(tabId: workspaceID, toIndex: toIndex) + } else { + plan = tabManager.workspaceReorderPlan( + tabId: workspaceID, + before: beforeWorkspaceID, + after: afterWorkspaceID + ) + } + guard let plan else { + return .notFound + } + if !dryRun { + _ = tabManager.reorderWorkspace(tabId: workspaceID, toIndex: plan.toIndex) + } + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved( + windowID: windowId, + plan: ControlWorkspaceReorderPlanItem( + workspaceID: plan.workspaceId, + fromIndex: plan.fromIndex, + toIndex: plan.toIndex + ) + ) + } + + func controlReorderWorkspacesMany( + routing: ControlRoutingSelectors, + workspaceIDs: [UUID], + dryRun: Bool + ) -> ControlWorkspaceReorderManyResolution { + guard let tabManager = resolveReorderManyTabManager(routing: routing, workspaceIDs: workspaceIDs) else { + return .tabManagerUnavailable + } + let result = tabManager.reorderWorkspaces(orderedWorkspaceIds: workspaceIDs, dryRun: dryRun) + switch result { + case .success(let planned): + let windowId = AppDelegate.shared?.windowId(for: tabManager) + let plans = planned.map { + ControlWorkspaceReorderPlanItem( + workspaceID: $0.workspaceId, + fromIndex: $0.fromIndex, + toIndex: $0.toIndex + ) + } + return .resolved(windowID: windowId, plans: plans) + case .failure(.duplicateWorkspace(let workspaceId)): + return .duplicateWorkspace(workspaceId) + case .failure(.workspaceNotFound(let workspaceId)): + return .workspaceNotFound(workspaceId) + } + } + + /// Mirrors the legacy `v2ResolveWorkspaceReorderManyTabManager`: an explicit + /// `window_id` wins, otherwise the first owning workspace's TabManager, + /// otherwise the routing fallback. + private func resolveReorderManyTabManager( + routing: ControlRoutingSelectors, + workspaceIDs: [UUID] + ) -> TabManager? { + if routing.hasWindowIDParam { + return resolveTabManager(routing: routing) + } + for workspaceId in workspaceIDs { + if let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) { + return owner + } + } + return resolveTabManager(routing: routing) + } + + // MARK: - Prompt submit / rename + + func controlSubmitWorkspacePrompt( + routing: ControlRoutingSelectors, + workspaceID: UUID, + message: String? + ) -> ControlWorkspacePromptSubmitResolution { + guard let tabManager = (AppDelegate.shared?.tabManagerFor(tabId: workspaceID)) + ?? resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + let iMessageModeEnabled = IMessageModeSettings.isEnabled() + guard let outcome = tabManager.handlePromptSubmit( + workspaceId: workspaceID, + message: message, + iMessageModeEnabled: iMessageModeEnabled + ) else { + return .notFound + } + let preview = tabManager.tabs.first(where: { $0.id == workspaceID })?.latestSubmittedMessage + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved( + windowID: windowId, + iMessageModeEnabled: iMessageModeEnabled, + messageRecorded: outcome.messageRecorded, + reordered: outcome.reordered, + index: outcome.index, + messagePreview: preview + ) + } + + func controlRenameWorkspace( + routing: ControlRoutingSelectors, + workspaceID: UUID, + title: String + ) -> ControlWorkspaceRoutedResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard tabManager.tabs.contains(where: { $0.id == workspaceID }) else { + return .notFound + } + tabManager.setCustomTitle(tabId: workspaceID, title: title) + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved(windowID: windowId) + } + + // MARK: - Navigation + + func controlSelectNextWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard tabManager.selectedTabId != nil else { return .notFound } + if let windowId = AppDelegate.shared?.windowId(for: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectNextTab() + guard let workspaceId = tabManager.selectedTabId else { return .notFound } + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved(workspaceID: workspaceId, windowID: windowId) + } + + func controlSelectPreviousWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard tabManager.selectedTabId != nil else { return .notFound } + if let windowId = AppDelegate.shared?.windowId(for: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.selectPreviousTab() + guard let workspaceId = tabManager.selectedTabId else { return .notFound } + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved(workspaceID: workspaceId, windowID: windowId) + } + + func controlSelectLastWorkspace(routing: ControlRoutingSelectors) -> ControlWorkspaceNavigationResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let before = tabManager.selectedTabId else { return .notFound } + if let windowId = AppDelegate.shared?.windowId(for: tabManager) { + _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) + setActiveTabManager(tabManager) + } + tabManager.navigateBack() + guard let after = tabManager.selectedTabId, after != before else { return .notFound } + let windowId = AppDelegate.shared?.windowId(for: tabManager) + return .resolved(workspaceID: after, windowID: windowId) + } + + // MARK: - Equalize + + func controlEqualizeWorkspaceSplits( + routing: ControlRoutingSelectors, + orientationFilter: String? + ) -> ControlWorkspaceEqualizeResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + guard let ws = resolveWorkspace(routing: routing, tabManager: tabManager) else { + return .notFound + } + let tree = ws.bonsplitController.treeSnapshot() + let equalizeResult = SplitEqualizer.equalize( + in: tree, + controller: ws.bonsplitController, + orientationFilter: orientationFilter + ) + return .resolved(workspaceID: ws.id, equalized: equalizeResult.didFullyEqualize) + } + + /// Mirrors the legacy `v2ResolveWorkspace(params:tabManager:)` precedence + /// using the pre-resolved routing selectors: workspace, then surface, then + /// pane (same TabManager), then the selected workspace. + private func resolveWorkspace( + routing: ControlRoutingSelectors, + tabManager: TabManager + ) -> Workspace? { + if let workspaceId = routing.workspaceID { + return tabManager.tabs.first(where: { $0.id == workspaceId }) + } + if let surfaceId = routing.surfaceID { + return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) + } + if let paneId = routing.paneID, + let located = v2LocatePane(paneId) { + guard located.tabManager === tabManager else { return nil } + return located.workspace + } + guard let workspaceId = tabManager.selectedTabId else { return nil } + return tabManager.tabs.first(where: { $0.id == workspaceId }) + } + + // MARK: - Remote + + func controlResolveRemoteWorkspaceID( + routing: ControlRoutingSelectors, + requestedWorkspaceID: UUID? + ) -> UUID? { + let fallbackTabManager = resolveTabManager(routing: routing) + return requestedWorkspaceID ?? fallbackTabManager?.selectedTabId + } + + func controlDisconnectWorkspaceRemote( + workspaceID: UUID, + clearConfiguration: Bool + ) -> ControlWorkspaceRemoteResolution { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceID), + let workspace = owner.tabs.first(where: { $0.id == workspaceID }) else { + return .notFound(workspaceID: workspaceID) + } + workspace.disconnectRemoteConnection(clearConfiguration: clearConfiguration) + let windowId = AppDelegate.shared?.windowId(for: owner) + return .resolved( + windowID: windowId, + workspaceID: workspace.id, + remoteStatus: JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]) + ) + } + + func controlReconnectWorkspaceRemote(workspaceID: UUID) -> ControlWorkspaceRemoteResolution { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceID), + let workspace = owner.tabs.first(where: { $0.id == workspaceID }) else { + return .notFound(workspaceID: workspaceID) + } + guard workspace.remoteConfiguration != nil else { + return .notConfigured(workspaceID: workspaceID) + } + workspace.reconnectRemoteConnection() + notifyRemotePTYControllerAvailabilityChanged() + let windowId = AppDelegate.shared?.windowId(for: owner) + return .resolved( + windowID: windowId, + workspaceID: workspace.id, + remoteStatus: JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]) + ) + } + + func controlWorkspaceRemoteForegroundAuthReady( + workspaceID: UUID, + foregroundAuthToken: String? + ) -> ControlWorkspaceRemoteResolution { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceID), + let workspace = owner.tabs.first(where: { $0.id == workspaceID }) else { + return .notFound(workspaceID: workspaceID) + } + workspace.notifyRemoteForegroundAuthenticationReady(token: foregroundAuthToken) + notifyRemotePTYControllerAvailabilityChanged() + let windowId = AppDelegate.shared?.windowId(for: owner) + return .resolved( + windowID: windowId, + workspaceID: workspace.id, + remoteStatus: JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]) + ) + } + + func controlWorkspaceRemoteStatus(workspaceID: UUID) -> ControlWorkspaceRemoteResolution { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceID), + let workspace = owner.tabs.first(where: { $0.id == workspaceID }) else { + return .notFound(workspaceID: workspaceID) + } + let windowId = AppDelegate.shared?.windowId(for: owner) + return .resolved( + windowID: windowId, + workspaceID: workspace.id, + remoteStatus: JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]) + ) + } + + func controlConfigureWorkspaceRemote( + params typedParams: [String: JSONValue], + workspaceID workspaceId: UUID + ) -> ControlCallResult { + // The configure body validates ~40 params against the app's + // `WorkspaceRemote*` types, so it stays app-side. Bridge the typed params + // back to the `[String: Any]` shape the legacy `v2*` param helpers expect + // so the acceptance is byte-identical. + let params: [String: Any] = typedParams.mapValues(\.foundationObject) + + guard let destination = v2String(params, "destination") else { + return .err(code: "invalid_params", message: "Missing destination", data: nil) + } + + var sshPort: Int? + if v2HasNonNullParam(params, "port") { + guard let parsedPort = v2StrictInt(params, "port"), + parsedPort > 0, + parsedPort <= 65535 else { + return .err(code: "invalid_params", message: "port must be 1-65535", data: nil) + } + sshPort = parsedPort + } + + var localProxyPort: Int? + if v2HasNonNullParam(params, "local_proxy_port") { + guard let parsedLocalProxyPort = v2StrictInt(params, "local_proxy_port"), + parsedLocalProxyPort > 0, + parsedLocalProxyPort <= 65535 else { + return .err(code: "invalid_params", message: "local_proxy_port must be 1-65535", data: nil) + } + localProxyPort = parsedLocalProxyPort + } + + let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) + let sshOptions = v2StringArray(params, "ssh_options") ?? [] + let transportRaw = v2RawString(params, "transport")? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let transport = WorkspaceRemoteTransport(rawValue: transportRaw ?? "") ?? .ssh + let autoConnect = v2Bool(params, "auto_connect") ?? true + var relayPort: Int? + if v2HasNonNullParam(params, "relay_port") { + guard let parsedRelayPort = v2StrictInt(params, "relay_port"), + parsedRelayPort > 0, + parsedRelayPort <= 65535 else { + return .err(code: "invalid_params", message: "relay_port must be 1-65535", data: nil) + } + relayPort = parsedRelayPort + } + let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) + let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) + let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? + .trimmingCharacters(in: .whitespacesAndNewlines) + let localSocketPath = v2RawString(params, "local_socket_path") + let hasExplicitAgentSocketPath = v2HasNonNullParam(params, "ssh_auth_sock") + let agentSocketPath = v2RawString(params, "ssh_auth_sock")? + .trimmingCharacters(in: .whitespacesAndNewlines) + let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? + .trimmingCharacters(in: .whitespacesAndNewlines) + var persistentDaemonSlot = v2RawString(params, "persistent_daemon_slot")? + .trimmingCharacters(in: .whitespacesAndNewlines) + if v2HasNonNullParam(params, "persistent_daemon_slot") { + guard let persistentDaemonSlot, + !persistentDaemonSlot.isEmpty, + persistentDaemonSlot.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil, + persistentDaemonSlot != ".", + persistentDaemonSlot != ".." else { + return .err( + code: "invalid_params", + message: "persistent_daemon_slot must contain only letters, numbers, '.', '_' or '-'", + data: nil + ) + } + } + let daemonWebSocketURL = v2RawString(params, "daemon_websocket_url")? + .trimmingCharacters(in: .whitespacesAndNewlines) + let daemonWebSocketToken = v2RawString(params, "daemon_websocket_token")? + .trimmingCharacters(in: .whitespacesAndNewlines) + let daemonWebSocketSessionID = v2RawString(params, "daemon_websocket_session_id")? + .trimmingCharacters(in: .whitespacesAndNewlines) + let daemonWebSocketExpiresAtUnix = (params["daemon_websocket_expires_at_unix"] as? Int64) + ?? Int64((params["daemon_websocket_expires_at_unix"] as? Double) ?? 0) + let rawDaemonHeaders = params["daemon_websocket_headers"] as? [String: Any] ?? [:] + let daemonWebSocketHeaders = rawDaemonHeaders.reduce(into: [String: String]()) { result, pair in + if let value = pair.value as? String { + result[pair.key] = value + } + } + let daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? + if let daemonWebSocketURL, + !daemonWebSocketURL.isEmpty, + let daemonWebSocketToken, + !daemonWebSocketToken.isEmpty, + let daemonWebSocketSessionID, + !daemonWebSocketSessionID.isEmpty { + daemonWebSocketEndpoint = WorkspaceRemoteWebSocketDaemonEndpoint( + url: daemonWebSocketURL, + headers: daemonWebSocketHeaders, + token: daemonWebSocketToken, + sessionId: daemonWebSocketSessionID, + expiresAtUnix: daemonWebSocketExpiresAtUnix + ) + } else { + daemonWebSocketEndpoint = nil + } + let preserveAfterTerminalExit = v2Bool(params, "preserve_after_terminal_exit") ?? false + if v2HasNonNullParam(params, "preserve_after_terminal_exit"), + v2Bool(params, "preserve_after_terminal_exit") == nil { + return .err( + code: "invalid_params", + message: "preserve_after_terminal_exit must be a boolean", + data: nil + ) + } + let skipDaemonBootstrap = v2Bool(params, "skip_daemon_bootstrap") ?? false + if persistentDaemonSlot != nil, !preserveAfterTerminalExit { + return .err( + code: "invalid_params", + message: "preserve_after_terminal_exit is required when persistent_daemon_slot is set", + data: nil + ) + } + if preserveAfterTerminalExit, + transport == .ssh, + !skipDaemonBootstrap, + daemonWebSocketEndpoint == nil, + persistentDaemonSlot == nil { + persistentDaemonSlot = "ssh-\(workspaceId.uuidString.lowercased())" + } + if relayPort != nil { + guard let relayID, !relayID.isEmpty else { + return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) + } + guard let relayToken, + relayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { + return .err(code: "invalid_params", message: "relay_token must be 64 lowercase hex characters when relay_port is set", data: nil) + } + } + +#if DEBUG + cmuxDebugLog( + "workspace.remote.configure.request workspace=\(workspaceId.uuidString.prefix(8)) " + + "target=\(destination) transport=\(transport.rawValue) port=\(sshPort.map(String.init) ?? "nil") " + + "autoConnect=\(autoConnect ? 1 : 0) relayPort=\(relayPort.map(String.init) ?? "nil") " + + "localSocket=\(localSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? localSocketPath! : "nil") " + + "sshAuthSock=\(agentSocketPath?.isEmpty == false ? 1 : 0) " + + "sshOptions=\(sshOptions.joined(separator: "|"))" + ) +#endif + + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return .err(code: "not_found", message: "Workspace not found", data: .object([ + "workspace_id": .string(workspaceId.uuidString), + "workspace_ref": controlWorkspaceRefValue(workspaceId), + ])) + } + + let config = WorkspaceRemoteConfiguration( + transport: transport, + destination: destination, + port: sshPort, + identityFile: identityFile?.isEmpty == true ? nil : identityFile, + sshOptions: sshOptions, + localProxyPort: localProxyPort, + relayPort: relayPort, + relayID: relayID?.isEmpty == true ? nil : relayID, + relayToken: relayToken?.isEmpty == true ? nil : relayToken, + localSocketPath: localSocketPath, + terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand, + foregroundAuthToken: foregroundAuthToken?.isEmpty == true ? nil : foregroundAuthToken, + agentSocketPath: WorkspaceRemoteConfiguration.resolvedAgentSocketPath( + sshOptions: sshOptions, + explicitAgentSocketPath: agentSocketPath, + explicitAgentSocketPathIsSet: hasExplicitAgentSocketPath + ), + daemonWebSocketEndpoint: daemonWebSocketEndpoint, + preserveAfterTerminalExit: preserveAfterTerminalExit, + persistentDaemonSlot: persistentDaemonSlot?.isEmpty == true ? nil : persistentDaemonSlot, + skipDaemonBootstrap: skipDaemonBootstrap + ) + workspace.configureRemoteConnection(config, autoConnect: autoConnect) + notifyRemotePTYControllerAvailabilityChanged() + + let windowId = AppDelegate.shared?.windowId(for: owner) + return .ok(.object([ + "window_id": controlWindowOrNull(windowId), + "window_ref": controlWindowRefValue(windowId), + "workspace_id": .string(workspace.id.uuidString), + "workspace_ref": controlWorkspaceRefValue(workspace.id), + "remote": JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]), + ])) + } + + func controlWorkspaceRemotePTYAttachEnd( + workspaceID workspaceId: UUID, + surfaceID surfaceId: UUID, + sessionID sessionID: String + ) -> ControlWorkspaceRemotePTYAttachEndResolution { + let located = AppDelegate.shared?.workspaceContainingPanel( + panelId: surfaceId, + preferredWorkspaceId: workspaceId + ) + let fallbackOwner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) + let fallbackWorkspace = fallbackOwner?.tabs.first(where: { $0.id == workspaceId }) + guard let owner = located?.tabManager ?? fallbackOwner, + let workspace = located?.workspace ?? fallbackWorkspace else { + return .notFound + } + let outcome = workspace.markRemotePTYAttachEnded(surfaceId: surfaceId, sessionID: sessionID) + let windowId = AppDelegate.shared?.windowId(for: owner) + return .resolved( + windowID: windowId, + workspaceID: workspace.id, + clearedRemotePTYSession: outcome.clearedRemotePTYSession, + untrackedRemoteTerminal: outcome.untrackedRemoteTerminal, + remoteStatus: JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]) + ) + } + + func controlWorkspaceRemoteTerminalSessionEnd( + workspaceID workspaceId: UUID, + surfaceID surfaceId: UUID, + relayPort: Int + ) -> ControlWorkspaceRemoteTerminalSessionEndResolution { + guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), + let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + return .notFound + } + workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) + let windowId = AppDelegate.shared?.windowId(for: owner) + return .resolved( + windowID: windowId, + workspaceID: workspace.id, + remoteStatus: JSONValue(foundationObject: workspace.remoteStatusPayload()) ?? .object([:]) + ) + } + + // MARK: - Ref helpers (mint through the shared registry the coordinator owns) + + /// The `workspace:N` ref JSON value for the configure result, minted through + /// the same handle registry the coordinator uses so refs stay consistent. + private func controlWorkspaceRefValue(_ uuid: UUID) -> JSONValue { + .string(controlCommandCoordinator.ensureRef(kind: .workspace, uuid: uuid)) + } + + /// The `window:N` ref JSON value (or `null` when absent) for the configure + /// result. + private func controlWindowRefValue(_ uuid: UUID?) -> JSONValue { + guard let uuid else { return .null } + return .string(controlCommandCoordinator.ensureRef(kind: .window, uuid: uuid)) + } + + /// The window id JSON value (or `null` when absent). + private func controlWindowOrNull(_ uuid: UUID?) -> JSONValue { + guard let uuid else { return .null } + return .string(uuid.uuidString) + } +} + +/// Local `JSONValue` null test for the create-input passthrough (the package's +/// own `isNull` is file-private to the coordinator extensions). +private extension JSONValue { + var isControlNull: Bool { + if case .null = self { return true } + return false + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 0586ae8ca051..200873e42f54 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -90,7 +90,7 @@ class TerminalController { private nonisolated let remotePTYControllerAvailabilityCondition = NSCondition() private nonisolated(unsafe) var remotePTYControllerAvailabilityGeneration: UInt64 = 0 - private var tabManager: TabManager? + var tabManager: TabManager? /// The shared auth coordinator + browser sign-in flow, injected once via /// `attachAuth` at app startup (AppDelegate `configure`) before the socket /// listener starts. Socket auth commands read these on the main actor. @@ -107,7 +107,7 @@ class TerminalController { // Accepted-connection consumer; runs until process exit (singleton). private nonisolated let socketConnectionsTask: Task // Per-surface dedupe for high-frequency report_* socket telemetry. - private nonisolated let socketFastPathState = SocketFastPathState() + nonisolated let socketFastPathState = SocketFastPathState() private nonisolated let myPid = getpid() private nonisolated static let socketCommandFocusAllowanceStackKey = "cmux.socketCommandFocusAllowanceStack" private nonisolated static let socketListenerFailureCaptureCooldown: TimeInterval = 60 @@ -1930,53 +1930,16 @@ class TerminalController { // Windows (`window.*`) are handled above by ControlCommandCoordinator. // 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.reorder_many": - return v2Result(id: id, self.v2WorkspaceReorderMany(params: params)) - case "workspace.prompt_submit": - return v2Result(id: id, self.v2WorkspacePromptSubmit(params: params)) - case "workspace.rename": - return v2Result(id: id, self.v2WorkspaceRename(params: params)) - // workspace.group.* handled by ControlCommandCoordinator. + // workspace.* (list/create/select/current/close/move_to_window/reorder[_many]/ + // prompt_submit/rename) + workspace.group.* handled by ControlCommandCoordinator. case "workspace.action": return v2Result(id: id, self.v2WorkspaceAction(params: params)) case "extension.sidebar.snapshot": return v2Result(id: id, self.v2ExtensionSidebarSnapshot(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)) - case "workspace.equalize_splits": - return v2Result(id: id, self.v2WorkspaceEqualizeSplits(params: params)) - case "workspace.remote.configure": - return v2Result(id: id, self.v2WorkspaceRemoteConfigure(params: params)) - case "workspace.remote.foreground_auth_ready": - return v2Result(id: id, self.v2WorkspaceRemoteForegroundAuthReady(params: params)) - case "workspace.remote.reconnect": - return v2Result(id: id, self.v2WorkspaceRemoteReconnect(params: params)) - case "workspace.remote.disconnect": - return v2Result(id: id, self.v2WorkspaceRemoteDisconnect(params: params)) - case "workspace.remote.status": - return v2Result(id: id, self.v2WorkspaceRemoteStatus(params: params)) - case "workspace.remote.pty_attach_end": - return v2Result(id: id, self.v2WorkspaceRemotePTYAttachEnd(params: params)) - case "workspace.remote.terminal_session_end": - return v2Result(id: id, self.v2WorkspaceRemoteTerminalSessionEnd(params: params)) + // workspace.next/previous/last/equalize_splits + workspace.remote.* (configure/ + // foreground_auth_ready/reconnect/disconnect/status/pty_attach_end/ + // terminal_session_end) handled by ControlCommandCoordinator. The worker-lane + // workspace.remote.pty_* methods stay on the app-side worker path. case "session.restore_previous": return v2Result(id: id, self.v2SessionRestorePrevious()) @@ -1991,25 +1954,10 @@ class TerminalController { // Feed (workstream): feed.jump/feed.list handled by ControlCommandCoordinator. - // 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.respawn": - return v2Result(id: id, self.v2SurfaceRespawn(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)) + // Surfaces / input: surface.list/current/focus/split/respawn/create/close/move/ + // reorder handled by ControlCommandCoordinator (surface.move forwards to the + // still-shared v2SurfaceMove). surface.action/tab.action/drag_to_split/split_off + // stay app-side. case "surface.action": return v2Result(id: id, self.v2TabAction(params: params)) case "tab.action": @@ -2018,32 +1966,10 @@ class TerminalController { return v2Result(id: id, self.v2SurfaceDragToSplit(params: params)) case "surface.split_off": return v2Result(id: id, self.v2SurfaceSplitOff(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.resume.set": - return v2Result(id: id, self.v2SurfaceResumeSet(params: params)) - case "surface.resume.get": - return v2Result(id: id, self.v2SurfaceResumeGet(params: params)) - case "surface.resume.clear": - return v2Result(id: id, self.v2SurfaceResumeClear(params: params)) - case "debug.terminals": - return v2Result(id: id, self.v2DebugTerminals(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.report_tty": - return v2Result(id: id, self.v2SurfaceReportTTY(params: params)) - case "surface.report_shell_state": - return v2Result(id: id, self.v2SurfaceReportShellState(params: params)) - case "surface.ports_kick": - return v2Result(id: id, self.v2SurfacePortsKick(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)) + // surface.refresh/health/resume.set/get/clear, debug.terminals (forwards to the + // still-shared v2DebugTerminals), surface.send_text/send_key/report_tty/ + // report_shell_state/ports_kick/clear_history/trigger_flash, and surface.read_text + // handled by ControlCommandCoordinator. // Panes // pane.* handled by ControlCommandCoordinator. @@ -2249,8 +2175,7 @@ class TerminalController { case "project.get_state": return v2Result(id: id, self.v2ProjectGetState(params: params)) - case "surface.read_text": - return v2Result(id: id, self.v2SurfaceReadText(params: params)) + // surface.read_text handled by ControlCommandCoordinator. #if DEBUG @@ -3942,55 +3867,7 @@ class TerminalController { // MARK: - V2 Workspace Methods - private func v2WorkspaceSummaryPayload( - workspace: Workspace, - index: Int?, - selected: Bool - ) -> [String: Any] { - var payload: [String: Any] = [ - "id": workspace.id.uuidString, - "ref": v2Ref(kind: .workspace, uuid: workspace.id), - "title": workspace.title, - "description": v2OrNull(workspace.customDescription), - "selected": selected, - "pinned": workspace.isPinned, - "listening_ports": workspace.listeningPorts, - "remote": workspace.remoteStatusPayload(), - "current_directory": v2OrNull(workspace.currentDirectory), - "custom_color": v2OrNull(workspace.customColor), - "latest_conversation_message": v2OrNull(workspace.latestConversationMessage), - "latest_submitted_message": v2OrNull(workspace.latestSubmittedMessage), - "latest_submitted_at": v2OrNull(workspace.latestSubmittedAt.map(CmuxEventBus.isoTimestamp)) - ] - if let index { - payload["index"] = index - } - return payload - } - - 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 - v2WorkspaceSummaryPayload( - workspace: ws, - index: index, - selected: ws.id == tabManager.selectedTabId - ) - } - } - let windowId = v2ResolveWindowId(tabManager: tabManager) - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspaces": workspaces - ]) - } private nonisolated func v2CustomSidebarValidate(params: [String: Any]) -> V2CallResult { let name = v2CustomSidebarName(params: params) @@ -4190,3380 +4067,1188 @@ class TerminalController { return trimmed.isEmpty ? nil : trimmed } - private func v2WorkspaceCreate( - params: [String: Any], - tabManager resolvedTabManager: TabManager? = nil - ) -> V2CallResult { - guard let tabManager = resolvedTabManager ?? v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } - let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) - let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil - let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) - let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil - let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] - let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in - let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { return } - result[key] = pair.value - } - let cwd: String? - if let workingDirectory { - cwd = workingDirectory - } else 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 - } - let requestedTitle = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) - let title = (requestedTitle?.isEmpty == false) ? requestedTitle : nil - let description = v2RawString(params, "description") - // Decode optional layout param (same JSON schema as cmux.json layout field). - // Validate before creating the workspace so malformed layouts fail fast. - var layoutNode: CmuxLayoutNode? - if let rawLayout = params["layout"] { - guard JSONSerialization.isValidJSONObject(rawLayout), - let layoutData = try? JSONSerialization.data(withJSONObject: rawLayout) else { - return .err(code: "invalid_params", message: "layout must be a valid JSON object", data: nil) - } - do { - layoutNode = try JSONDecoder().decode(CmuxLayoutNode.self, from: layoutData) - } catch { - return .err(code: "invalid_params", message: "Invalid layout: \(error.localizedDescription)", data: nil) - } - } - var newId: UUID? - var initialSurfaceId: UUID? - let shouldFocus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - let shouldEagerLoadTerminal = v2Bool(params, "eager_load_terminal") ?? !shouldFocus - let shouldAutoRefreshMetadata = v2Bool(params, "auto_refresh_metadata") ?? true - v2MainSync { - let ws = tabManager.addWorkspace( - title: title, - workingDirectory: cwd, - initialTerminalCommand: layoutNode == nil ? initialCommand : nil, - initialTerminalEnvironment: layoutNode == nil ? initialEnv : [:], - select: shouldFocus, - eagerLoadTerminal: shouldEagerLoadTerminal, - autoRefreshMetadata: shouldAutoRefreshMetadata - ) - ws.setCustomDescription(description) - if let layoutNode { - ws.applyCustomLayout(layoutNode, baseCwd: cwd ?? ws.currentDirectory) - } - newId = ws.id - initialSurfaceId = ws.focusedPanelId - } - 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), - "surface_id": v2OrNull(initialSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: initialSurfaceId) - ]) - } - 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 }) { - // If this workspace belongs to another window, bring it forward so focus is visible. - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) - } - tabManager.selectWorkspace(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? - var wsPayload: [String: Any]? - v2MainSync { - wsId = tabManager.selectedTabId - if let wsId, let workspace = tabManager.tabs.first(where: { $0.id == wsId }) { - let index = tabManager.tabs.firstIndex(where: { $0.id == wsId }) - wsPayload = v2WorkspaceSummaryPayload( - workspace: workspace, - index: index, - selected: true - ) - } - } - 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), - "workspace": wsPayload ?? NSNull() - ]) - } - 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 - var protected = false - v2MainSync { - if let ws = tabManager.tabs.first(where: { $0.id == wsId }) { - guard tabManager.canCloseWorkspace(ws) else { - protected = true - found = true - return - } - tabManager.closeWorkspace(ws) - found = true - } - } - let windowId = v2ResolveWindowId(tabManager: tabManager) - if protected { - return .err(code: "protected", message: workspaceCloseProtectedMessage(), data: [ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "workspace_id": wsId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: wsId), - "pinned": true - ]) - } - 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 workspaceCloseProtectedMessage() -> String { - String( - localized: "workspace.closeProtected.message", - defaultValue: "Pinned workspaces can't be closed while pinned. Unpin the workspace first." - ) - } - 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") ?? false) - 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 dryRun = v2Bool(params, "dry_run") ?? false - 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 plan: WorkspaceReorderPlanItem? + + + private nonisolated func v2RequestedRemotePTYWorkspaceID(params: [String: Any]) -> ( + workspaceId: UUID?, + error: V2CallResult? + ) { + var workspaceId: UUID? + var invalidWorkspaceID = false v2MainSync { - if let index { - plan = tabManager.workspaceReorderPlan(tabId: workspaceId, toIndex: index) - } else { - plan = tabManager.workspaceReorderPlan(tabId: workspaceId, before: beforeId, after: afterId) - } - if let plan, !dryRun { - _ = tabManager.reorderWorkspace(tabId: workspaceId, toIndex: plan.toIndex) - } + v2RefreshKnownRefs() + workspaceId = v2UUID(params, "workspace_id") + invalidWorkspaceID = v2HasNonNullParam(params, "workspace_id") && workspaceId == nil } - - guard let plan else { - return .err(code: "not_found", message: "Workspace not found", data: ["workspace_id": workspaceId.uuidString]) + if invalidWorkspaceID { + return ( + nil, + .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + ) } - - let windowId = v2ResolveWindowId(tabManager: tabManager) - var payload = v2WorkspaceReorderPlanPayload(plan, windowId: windowId) - payload["dry_run"] = dryRun - payload["index"] = plan.toIndex - payload["plan"] = [v2WorkspaceReorderPlanPayload(plan, windowId: windowId)] - payload["events"] = (!dryRun && plan.fromIndex != plan.toIndex) - ? [v2WorkspaceReorderPlanPayload(plan, windowId: windowId)] - : [] - return .ok(payload) + return (workspaceId, nil) } - private func v2WorkspaceReorderMany(params: [String: Any]) -> V2CallResult { - let rawOrder = v2WorkspaceReorderManyOrder(params) - if let invalid = rawOrder.invalidValue { - return .err( - code: "invalid_params", - message: workspaceReorderManyInvalidWorkspaceMessage(), - data: ["workspace": invalid] - ) + private nonisolated func v2RequestedRemotePTYSurfaceID(params: [String: Any]) -> ( + surfaceId: UUID?, + error: V2CallResult? + ) { + var surfaceId: UUID? + var invalidSurfaceID = false + v2MainSync { + v2RefreshKnownRefs() + surfaceId = v2UUID(params, "surface_id") + invalidSurfaceID = v2HasNonNullParam(params, "surface_id") && surfaceId == nil } - let order = rawOrder.order - guard !order.isEmpty else { - return .err( - code: "invalid_params", - message: workspaceReorderManyMissingOrderMessage(), - data: nil + if invalidSurfaceID { + return ( + nil, + .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) ) } + return (surfaceId, nil) + } - var workspaceIds: [UUID] = [] - workspaceIds.reserveCapacity(order.count) - for raw in order { - guard let workspaceId = v2UUIDAny(raw) else { - return .err( - code: "invalid_params", - message: workspaceReorderManyInvalidWorkspaceMessage(), - data: ["workspace": raw] - ) - } - workspaceIds.append(workspaceId) + private nonisolated func v2ResolveRemotePTYTarget( + params: [String: Any], + requestedWorkspaceId: UUID?, + preferredSurfaceId: UUID? = nil + ) -> (target: RemotePTYSocketTarget?, error: V2CallResult?) { + if v2HasNonNullParam(params, "allow_moved_surface"), + v2Bool(params, "allow_moved_surface") == nil { + return ( + nil, + .err(code: "invalid_params", message: "Missing or invalid allow_moved_surface", data: nil) + ) } - - guard let tabManager = v2ResolveWorkspaceReorderManyTabManager(params: params, workspaceIds: workspaceIds) else { - return .err(code: "unavailable", message: workspaceReorderManyTabManagerUnavailableMessage(), data: nil) + let allowMovedSurface = v2Bool(params, "allow_moved_surface") ?? false + let requestedSessionID = v2RawString(params, "session_id").flatMap { raw -> String? in + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed } + var resolvedWorkspaceId: UUID? + var target: RemotePTYSocketTarget? + var workspaceMismatchData: [String: Any]? + + v2MainSync { + v2RefreshKnownRefs() + let fallbackTabManager = v2ResolveTabManager(params: params) + let fallbackWorkspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId + var owner: TabManager? + var workspace: Workspace? + if let preferredSurfaceId { + if let fallbackTabManager, + let surfaceWorkspace = fallbackTabManager.tabs.first(where: { + $0.panels[preferredSurfaceId] != nil + && $0.surfaceIdFromPanelId(preferredSurfaceId) != nil + }) { + owner = fallbackTabManager + workspace = surfaceWorkspace + } else if let located = AppDelegate.shared?.workspaceContainingPanel( + panelId: preferredSurfaceId, + preferredWorkspaceId: fallbackWorkspaceId + ) { + owner = located.tabManager + workspace = located.workspace + } + } + if workspace == nil, + let fallbackWorkspaceId, + let fallbackOwner = AppDelegate.shared?.tabManagerFor(tabId: fallbackWorkspaceId), + let fallbackWorkspace = fallbackOwner.tabs.first(where: { $0.id == fallbackWorkspaceId }) { + owner = fallbackOwner + workspace = fallbackWorkspace + } + resolvedWorkspaceId = workspace?.id ?? fallbackWorkspaceId + guard let owner, let workspace else { + return + } + if let requestedWorkspaceId, + workspace.id != requestedWorkspaceId { + let matchedMovedSurface = allowMovedSurface + && preferredSurfaceId.map { + workspace.remotePTYSessionIDMatches(panelId: $0, sessionID: requestedSessionID) + } == true + guard matchedMovedSurface else { + workspaceMismatchData = [ + "workspace_id": requestedWorkspaceId.uuidString, + "workspace_ref": v2Ref(kind: .workspace, uuid: requestedWorkspaceId), + "surface_id": v2OrNull(preferredSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: preferredSurfaceId), + "resolved_workspace_id": workspace.id.uuidString, + "resolved_workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), + ] + return + } + } - let dryRun = v2Bool(params, "dry_run") ?? false - let result = v2MainSync { - tabManager.reorderWorkspaces(orderedWorkspaceIds: workspaceIds, dryRun: dryRun) + let windowId = v2ResolveWindowId(tabManager: owner) + target = RemotePTYSocketTarget( + controller: workspace.remotePTYSessionControllerForSocketCommand(), + windowId: windowId, + windowRef: v2Ref(kind: .window, uuid: windowId), + workspaceId: workspace.id, + workspaceRef: v2Ref(kind: .workspace, uuid: workspace.id), + workspaceTitle: workspace.title + ) } - let plans: [WorkspaceReorderPlanItem] - switch result { - case .success(let planned): - plans = planned - case .failure(.duplicateWorkspace(let workspaceId)): - return .err( - code: "invalid_params", - message: workspaceReorderManyDuplicateWorkspaceMessage(), - data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) - ] + if let workspaceMismatchData { + return ( + nil, + .err( + code: "invalid_params", + message: "surface_id does not belong to workspace_id", + data: workspaceMismatchData + ) ) - case .failure(.workspaceNotFound(let workspaceId)): - return .err( - code: "not_found", - message: workspaceReorderManyWorkspaceNotFoundMessage(), - data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) - ] + } + guard let resolvedWorkspaceId else { + return ( + nil, + .err(code: "invalid_params", message: "Missing workspace_id", data: nil) ) } - - let windowId = v2ResolveWindowId(tabManager: tabManager) - let planPayloads = plans.map { v2WorkspaceReorderPlanPayload($0, windowId: windowId) } - return .ok([ - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "dry_run": dryRun, - "plan": planPayloads, - "events": dryRun ? [] : planPayloads.filter { item in - (item["from_index"] as? Int) != (item["to_index"] as? Int) - } - ]) + guard let target else { + return ( + nil, + .err( + code: "not_found", + message: "Workspace not found", + data: v2RemotePTYWorkspaceData(workspaceId: resolvedWorkspaceId) + ) + ) + } + return (target, nil) } - private func v2ResolveWorkspaceReorderManyTabManager(params: [String: Any], workspaceIds: [UUID]) -> TabManager? { - if v2HasNonNullParam(params, "window_id") { - return v2ResolveTabManager(params: params) - } - for workspaceId in workspaceIds { - if let owner = v2ResolveWorkspaceOwner(workspaceId) { - return owner - } - } - return v2ResolveTabManager(params: params) + nonisolated func notifyRemotePTYControllerAvailabilityChanged() { + remotePTYControllerAvailabilityCondition.lock() + remotePTYControllerAvailabilityGeneration &+= 1 + remotePTYControllerAvailabilityCondition.broadcast() + remotePTYControllerAvailabilityCondition.unlock() } - private func v2WorkspaceReorderManyOrder(_ params: [String: Any]) -> (order: [String], invalidValue: String?) { - if let raw = params["workspace_ids"], !(raw is NSNull) { - if let workspaceIds = raw as? [String] { - return v2NormalizeWorkspaceReorderManyOrder(workspaceIds) + private nonisolated func v2ResolveRemotePTYTargetWaitingForController( + params: [String: Any], + requestedWorkspaceId: UUID?, + preferredSurfaceId: UUID?, + deadline: Date + ) -> (target: RemotePTYSocketTarget?, error: V2CallResult?) { + var observedGeneration: UInt64? + + while true { + let resolved = v2ResolveRemotePTYTarget( + params: params, + requestedWorkspaceId: requestedWorkspaceId, + preferredSurfaceId: preferredSurfaceId + ) + if let error = resolved.error { + return (nil, error) } - if let workspaceIds = raw as? [Any] { - var strings: [String] = [] - strings.reserveCapacity(workspaceIds.count) - for item in workspaceIds { - guard let stringItem = item as? String else { - return ([], v2WorkspaceReorderManyInvalidValueDescription( - item, - fallback: "" - )) - } - strings.append(stringItem) - } - return v2NormalizeWorkspaceReorderManyOrder(strings) + guard let target = resolved.target else { + return resolved } - if let workspaceId = raw as? String { - return v2NormalizeWorkspaceReorderManyOrder([workspaceId]) + if target.controller != nil || Date() >= deadline { + return (target, nil) } - return ([], v2WorkspaceReorderManyInvalidValueDescription( - raw, - fallback: "" - )) - } - - guard let order = params["order"], !(order is NSNull) else { return ([], nil) } - guard let orderString = order as? String else { - return ([], v2WorkspaceReorderManyInvalidValueDescription( - order, - fallback: "" - )) - } - let refs = orderString - .split(separator: ",", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - return v2NormalizeWorkspaceReorderManyOrder(refs) - } - private func v2NormalizeWorkspaceReorderManyOrder(_ rawItems: [String]) -> (order: [String], invalidValue: String?) { - var order: [String] = [] - order.reserveCapacity(rawItems.count) - for raw in rawItems { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return ([], raw) + remotePTYControllerAvailabilityCondition.lock() + let currentGeneration = remotePTYControllerAvailabilityGeneration + guard let previousGeneration = observedGeneration else { + observedGeneration = currentGeneration + remotePTYControllerAvailabilityCondition.unlock() + continue + } + if previousGeneration != currentGeneration { + observedGeneration = currentGeneration + remotePTYControllerAvailabilityCondition.unlock() + continue } - order.append(trimmed) + _ = remotePTYControllerAvailabilityCondition.wait(until: deadline) + observedGeneration = remotePTYControllerAvailabilityGeneration + remotePTYControllerAvailabilityCondition.unlock() } - return (order, nil) } - private func v2WorkspaceReorderManyInvalidValueDescription( - _ value: Any, - fallback: String - ) -> String { - guard JSONSerialization.isValidJSONObject(["value": value]), - let data = try? JSONSerialization.data(withJSONObject: ["value": value], options: []), - let encoded = String(data: data, encoding: .utf8) else { - return fallback + private nonisolated func v2RemotePTYWorkspaceData(workspaceId: UUID) -> [String: Any] { + var workspaceRef: Any = NSNull() + v2MainSync { + workspaceRef = v2Ref(kind: .workspace, uuid: workspaceId) } - return encoded + return [ + "workspace_id": workspaceId.uuidString, + "workspace_ref": workspaceRef, + ] } - private func v2WorkspaceReorderPlanPayload( - _ plan: WorkspaceReorderPlanItem, - windowId: UUID? - ) -> [String: Any] { + private nonisolated func v2RemotePTYTargetPayload(_ target: RemotePTYSocketTarget) -> [String: Any] { [ - "workspace_id": plan.workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: plan.workspaceId), - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId), - "from_index": plan.fromIndex, - "to_index": plan.toIndex + "window_id": v2OrNull(target.windowId?.uuidString), + "window_ref": target.windowRef, + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "workspace_title": target.workspaceTitle, ] } - private func workspaceReorderManyMissingOrderMessage() -> String { - String( - localized: "socket.workspace.reorderMany.missingOrder", - defaultValue: "Missing workspace_ids" - ) - } - - private func workspaceReorderManyDuplicateWorkspaceMessage() -> String { - String( - localized: "socket.workspace.reorderMany.duplicateWorkspace", - defaultValue: "Duplicate workspace in order" - ) - } - - private func workspaceReorderManyWorkspaceNotFoundMessage() -> String { - String( - localized: "socket.workspace.reorderMany.workspaceNotFound", - defaultValue: "Workspace not found" - ) - } - - private func workspaceReorderManyInvalidWorkspaceMessage() -> String { - String( - localized: "socket.workspace.reorderMany.invalidWorkspace", - defaultValue: "Invalid workspace id or ref" - ) - } - - private func workspaceReorderManyTabManagerUnavailableMessage() -> String { - String( - localized: "socket.workspace.reorderMany.tabManagerUnavailable", - defaultValue: "TabManager not available" - ) - } - - private func v2WorkspacePromptSubmit(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) - } - - let messageKeys = ["message", "prompt", "text", "body"] - for key in messageKeys { - guard let raw = params[key], !(raw is NSNull) else { continue } - guard raw is String else { - return .err(code: "invalid_params", message: "\(key) must be a string", data: nil) - } + private nonisolated func v2WorkspaceRemotePTYSessions(params: [String: Any]) -> V2CallResult { + if v2HasNonNullParam(params, "all_workspaces"), v2Bool(params, "all_workspaces") == nil { + return .err(code: "invalid_params", message: "Missing or invalid all_workspaces", data: nil) } - let message = messageKeys.lazy.compactMap { self.v2RawString(params, $0) }.first - guard let tabManager = v2ResolveWorkspaceOwner(workspaceId) ?? v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + let allWorkspaces = v2Bool(params, "all_workspaces") ?? false + let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) + if let error = workspaceSelection.error { return error } + let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) + if let error = surfaceSelection.error { return error } + let requestedWorkspaceId = workspaceSelection.workspaceId + if allWorkspaces, requestedWorkspaceId != nil { + return .err(code: "invalid_params", message: "all_workspaces cannot be combined with workspace_id", data: nil) } - let iMessageModeEnabled = IMessageModeSettings.isEnabled() - var outcome: (messageRecorded: Bool, reordered: Bool, index: Int)? - var preview: String? + if allWorkspaces { + var targets: [RemotePTYSocketTarget] = [] + v2MainSync { + v2RefreshKnownRefs() + guard let app = AppDelegate.shared else { return } + for summary in app.listMainWindowSummaries() { + guard let owner = app.tabManagerFor(windowId: summary.windowId) else { continue } + for workspace in owner.tabs where workspace.isRemoteWorkspace { + targets.append( + RemotePTYSocketTarget( + controller: workspace.remotePTYSessionControllerForSocketCommand(), + windowId: summary.windowId, + windowRef: v2Ref(kind: .window, uuid: summary.windowId), + workspaceId: workspace.id, + workspaceRef: v2Ref(kind: .workspace, uuid: workspace.id), + workspaceTitle: workspace.title + ) + ) + } + } + } - // Socket handlers run off the main thread; prompt submit mutates - // @Published workspace/sidebar state and workspace ordering. - v2MainSync { - outcome = tabManager.handlePromptSubmit( - workspaceId: workspaceId, - message: message, - iMessageModeEnabled: iMessageModeEnabled - ) - preview = tabManager.tabs.first(where: { $0.id == workspaceId })?.latestSubmittedMessage - } - - guard let outcome 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), - "i_message_mode_enabled": iMessageModeEnabled, - "message_recorded": outcome.messageRecorded, - "message_preview": v2OrNull(preview), - "reordered": outcome.reordered, - "index": outcome.index - ]) - } + var sessions: [[String: Any]] = [] + var errors: [[String: Any]] = [] + for target in targets { + guard let controller = target.controller else { + var payload = v2RemotePTYTargetPayload(target) + payload["error"] = "remote connection is not active" + errors.append(payload) + continue + } + do { + let workspaceSessions = try controller.listPTYSessions() + sessions.append(contentsOf: workspaceSessions.map { + v2RemotePTYSessionPayload($0, target: target) + }) + } catch { + var payload = v2RemotePTYTargetPayload(target) + payload["error"] = v2RemotePTYUserFacingErrorMessage(error) + errors.append(payload) + } + } - 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) + return .ok([ + "all_workspaces": true, + "workspace_count": targets.count, + "sessions": sessions, + "errors": errors, + ]) } - 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 + let resolved = v2ResolveRemotePTYTarget( + params: params, + requestedWorkspaceId: requestedWorkspaceId, + preferredSurfaceId: surfaceSelection.surfaceId + ) + if let error = resolved.error { return error } + guard let target = resolved.target else { + return .err(code: "not_found", message: "Workspace not found", data: nil) } - - guard renamed else { - return .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId) + guard let controller = target.controller else { + return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, ]) } - 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 } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(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) + do { + let sessions = try controller.listPTYSessions() + var payload = v2RemotePTYTargetPayload(target) + payload["sessions"] = sessions.map { v2RemotePTYSessionPayload($0, target: target) } + return .ok(payload) + } catch { + return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, ]) } - 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 } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(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 nonisolated func v2RemotePTYSessionPayload( + _ session: [String: Any], + target: RemotePTYSocketTarget + ) -> [String: Any] { + var payload = session + payload["window_id"] = v2OrNull(target.windowId?.uuidString) + payload["window_ref"] = target.windowRef + payload["workspace_id"] = target.workspaceId.uuidString + payload["workspace_ref"] = target.workspaceRef + payload["workspace_title"] = target.workspaceTitle + return payload } - private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + private nonisolated func v2WorkspaceRemotePTYClose(params: [String: Any]) -> V2CallResult { + let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) + if let error = workspaceSelection.error { return error } + guard let sessionID = v2RawString(params, "session_id")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) } + let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) + if let error = surfaceSelection.error { return error } - var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil) - v2MainSync { - guard let before = tabManager.selectedTabId else { return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(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) - ]) + let resolved = v2ResolveRemotePTYTarget( + params: params, + requestedWorkspaceId: workspaceSelection.workspaceId, + preferredSurfaceId: surfaceSelection.surfaceId + ) + if let error = resolved.error { return error } + guard let target = resolved.target else { + return .err(code: "not_found", message: "Workspace not found", data: nil) } - return result - } - - private func v2WorkspaceEqualizeSplits(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) + guard let controller = target.controller else { + return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, + ]) } - let orientationFilter = v2String(params, "orientation") - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } - let tree = ws.bonsplitController.treeSnapshot() - let equalizeResult = SplitEqualizer.equalize( - in: tree, - controller: ws.bonsplitController, - orientationFilter: orientationFilter - ) - result = .ok([ - "workspace_id": ws.id.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: ws.id), - "equalized": equalizeResult.didFullyEqualize + do { + try controller.closePTYSession(sessionID: sessionID) + var payload = v2RemotePTYTargetPayload(target) + payload["session_id"] = sessionID + payload["closed"] = true + return .ok(payload) + } catch { + return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, ]) } - return result } - private func v2WorkspaceRemoteConfigure(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + private nonisolated func v2WorkspaceRemotePTYDetach(params: [String: Any]) -> V2CallResult { + let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) + if let error = workspaceSelection.error { return error } + guard let sessionID = v2RawString(params, "session_id")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) } - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + guard let attachmentID = v2RawString(params, "attachment_id")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !attachmentID.isEmpty else { + return .err(code: "invalid_params", message: "Missing attachment_id", data: nil) } - guard let destination = v2String(params, "destination") else { - return .err(code: "invalid_params", message: "Missing destination", data: nil) + guard let attachmentToken = v2RawString(params, "attachment_token")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !attachmentToken.isEmpty else { + return .err(code: "invalid_params", message: "Missing attachment_token", data: nil) } + let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) + if let error = surfaceSelection.error { return error } - var sshPort: Int? - if v2HasNonNullParam(params, "port") { - guard let parsedPort = v2StrictInt(params, "port"), - parsedPort > 0, - parsedPort <= 65535 else { - return .err(code: "invalid_params", message: "port must be 1-65535", data: nil) - } - sshPort = parsedPort + let resolved = v2ResolveRemotePTYTarget( + params: params, + requestedWorkspaceId: workspaceSelection.workspaceId, + preferredSurfaceId: surfaceSelection.surfaceId + ) + if let error = resolved.error { return error } + guard let target = resolved.target else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + guard let controller = target.controller else { + return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, + "attachment_id": attachmentID, + ]) } - // Internal deterministic test hook: pin the local proxy listener port to force bind conflicts. - var localProxyPort: Int? - if v2HasNonNullParam(params, "local_proxy_port") { - guard let parsedLocalProxyPort = v2StrictInt(params, "local_proxy_port"), - parsedLocalProxyPort > 0, - parsedLocalProxyPort <= 65535 else { - return .err(code: "invalid_params", message: "local_proxy_port must be 1-65535", data: nil) - } - localProxyPort = parsedLocalProxyPort + do { + try controller.detachPTYSession( + sessionID: sessionID, + attachmentID: attachmentID, + attachmentToken: attachmentToken + ) + var payload = v2RemotePTYTargetPayload(target) + payload["session_id"] = sessionID + payload["attachment_id"] = attachmentID + payload["detached"] = true + return .ok(payload) + } catch { + return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, + "attachment_id": attachmentID, + ]) } + } - let identityFile = v2RawString(params, "identity_file")?.trimmingCharacters(in: .whitespacesAndNewlines) - let sshOptions = v2StringArray(params, "ssh_options") ?? [] - let transportRaw = v2RawString(params, "transport")? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased() - let transport = WorkspaceRemoteTransport(rawValue: transportRaw ?? "") ?? .ssh - let autoConnect = v2Bool(params, "auto_connect") ?? true - var relayPort: Int? - if v2HasNonNullParam(params, "relay_port") { - guard let parsedRelayPort = v2StrictInt(params, "relay_port"), - parsedRelayPort > 0, - parsedRelayPort <= 65535 else { - return .err(code: "invalid_params", message: "relay_port must be 1-65535", data: nil) - } - relayPort = parsedRelayPort - } - let relayID = v2RawString(params, "relay_id")?.trimmingCharacters(in: .whitespacesAndNewlines) - let relayToken = v2RawString(params, "relay_token")?.trimmingCharacters(in: .whitespacesAndNewlines) - let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? - .trimmingCharacters(in: .whitespacesAndNewlines) - let localSocketPath = v2RawString(params, "local_socket_path") - let hasExplicitAgentSocketPath = v2HasNonNullParam(params, "ssh_auth_sock") - let agentSocketPath = v2RawString(params, "ssh_auth_sock")? - .trimmingCharacters(in: .whitespacesAndNewlines) - let terminalStartupCommand = v2RawString(params, "terminal_startup_command")? - .trimmingCharacters(in: .whitespacesAndNewlines) - var persistentDaemonSlot = v2RawString(params, "persistent_daemon_slot")? - .trimmingCharacters(in: .whitespacesAndNewlines) - if v2HasNonNullParam(params, "persistent_daemon_slot") { - guard let persistentDaemonSlot, - !persistentDaemonSlot.isEmpty, - persistentDaemonSlot.range(of: "^[A-Za-z0-9._-]{1,128}$", options: .regularExpression) != nil, - persistentDaemonSlot != ".", - persistentDaemonSlot != ".." else { - return .err( - code: "invalid_params", - message: "persistent_daemon_slot must contain only letters, numbers, '.', '_' or '-'", - data: nil - ) - } + private nonisolated func v2WorkspaceRemotePTYBridge(params: [String: Any]) -> V2CallResult { + let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) + if let error = workspaceSelection.error { return error } + guard let sessionID = v2RawString(params, "session_id")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) } - let daemonWebSocketURL = v2RawString(params, "daemon_websocket_url")? - .trimmingCharacters(in: .whitespacesAndNewlines) - let daemonWebSocketToken = v2RawString(params, "daemon_websocket_token")? - .trimmingCharacters(in: .whitespacesAndNewlines) - let daemonWebSocketSessionID = v2RawString(params, "daemon_websocket_session_id")? + let attachmentID = (v2RawString(params, "attachment_id")? + .trimmingCharacters(in: .whitespacesAndNewlines)) + .flatMap { $0.isEmpty ? nil : $0 } + ?? UUID().uuidString.lowercased() + let command = v2RawString(params, "command")? .trimmingCharacters(in: .whitespacesAndNewlines) - let daemonWebSocketExpiresAtUnix = (params["daemon_websocket_expires_at_unix"] as? Int64) - ?? Int64((params["daemon_websocket_expires_at_unix"] as? Double) ?? 0) - let rawDaemonHeaders = params["daemon_websocket_headers"] as? [String: Any] ?? [:] - let daemonWebSocketHeaders = rawDaemonHeaders.reduce(into: [String: String]()) { result, pair in - if let value = pair.value as? String { - result[pair.key] = value - } - } - let daemonWebSocketEndpoint: WorkspaceRemoteWebSocketDaemonEndpoint? - if let daemonWebSocketURL, - !daemonWebSocketURL.isEmpty, - let daemonWebSocketToken, - !daemonWebSocketToken.isEmpty, - let daemonWebSocketSessionID, - !daemonWebSocketSessionID.isEmpty { - daemonWebSocketEndpoint = WorkspaceRemoteWebSocketDaemonEndpoint( - url: daemonWebSocketURL, - headers: daemonWebSocketHeaders, - token: daemonWebSocketToken, - sessionId: daemonWebSocketSessionID, - expiresAtUnix: daemonWebSocketExpiresAtUnix - ) - } else { - daemonWebSocketEndpoint = nil - } - let preserveAfterTerminalExit = v2Bool(params, "preserve_after_terminal_exit") ?? false - if v2HasNonNullParam(params, "preserve_after_terminal_exit"), - v2Bool(params, "preserve_after_terminal_exit") == nil { - return .err( - code: "invalid_params", - message: "preserve_after_terminal_exit must be a boolean", - data: nil + let requireExisting = v2Bool(params, "require_existing") ?? false + let waitForReady = v2Bool(params, "wait_for_ready") ?? false + let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) + if let error = surfaceSelection.error { return error } + let preferredSurfaceId = surfaceSelection.surfaceId ?? UUID(uuidString: attachmentID) + + let controllerDeadline = Date().addingTimeInterval(waitForReady ? 90.0 : 8.0) + let resolved = waitForReady + ? v2ResolveRemotePTYTargetWaitingForController( + params: params, + requestedWorkspaceId: workspaceSelection.workspaceId, + preferredSurfaceId: preferredSurfaceId, + deadline: controllerDeadline ) - } - let skipDaemonBootstrap = v2Bool(params, "skip_daemon_bootstrap") ?? false - if persistentDaemonSlot != nil, !preserveAfterTerminalExit { - return .err( - code: "invalid_params", - message: "preserve_after_terminal_exit is required when persistent_daemon_slot is set", - data: nil + : v2ResolveRemotePTYTarget( + params: params, + requestedWorkspaceId: workspaceSelection.workspaceId, + preferredSurfaceId: preferredSurfaceId ) + if let error = resolved.error { return error } + guard let target = resolved.target else { + return .err(code: "not_found", message: "Workspace not found", data: nil) } - if preserveAfterTerminalExit, - transport == .ssh, - !skipDaemonBootstrap, - daemonWebSocketEndpoint == nil, - persistentDaemonSlot == nil { - persistentDaemonSlot = "ssh-\(workspaceId.uuidString.lowercased())" - } - if relayPort != nil { - guard let relayID, !relayID.isEmpty else { - return .err(code: "invalid_params", message: "relay_id is required when relay_port is set", data: nil) - } - guard let relayToken, - relayToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { - return .err(code: "invalid_params", message: "relay_token must be 64 lowercase hex characters when relay_port is set", data: nil) - } + guard let controller = target.controller else { + return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, + "attachment_id": attachmentID, + ]) } -#if DEBUG - cmuxDebugLog( - "workspace.remote.configure.request workspace=\(workspaceId.uuidString.prefix(8)) " + - "target=\(destination) transport=\(transport.rawValue) port=\(sshPort.map(String.init) ?? "nil") " + - "autoConnect=\(autoConnect ? 1 : 0) relayPort=\(relayPort.map(String.init) ?? "nil") " + - "localSocket=\(localSocketPath?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? localSocketPath! : "nil") " + - "sshAuthSock=\(agentSocketPath?.isEmpty == false ? 1 : 0) " + - "sshOptions=\(sshOptions.joined(separator: "|"))" - ) -#endif - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - - // Must run on main for v2MainSync because Workspace.configureRemoteConnection mutates TabManager/UI-owned workspace state. - v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return - } - - let config = WorkspaceRemoteConfiguration( - transport: transport, - destination: destination, - port: sshPort, - identityFile: identityFile?.isEmpty == true ? nil : identityFile, - sshOptions: sshOptions, - localProxyPort: localProxyPort, - relayPort: relayPort, - relayID: relayID?.isEmpty == true ? nil : relayID, - relayToken: relayToken?.isEmpty == true ? nil : relayToken, - localSocketPath: localSocketPath, - terminalStartupCommand: terminalStartupCommand?.isEmpty == true ? nil : terminalStartupCommand, - foregroundAuthToken: foregroundAuthToken?.isEmpty == true ? nil : foregroundAuthToken, - agentSocketPath: WorkspaceRemoteConfiguration.resolvedAgentSocketPath( - sshOptions: sshOptions, - explicitAgentSocketPath: agentSocketPath, - explicitAgentSocketPathIsSet: hasExplicitAgentSocketPath - ), - daemonWebSocketEndpoint: daemonWebSocketEndpoint, - preserveAfterTerminalExit: preserveAfterTerminalExit, - persistentDaemonSlot: persistentDaemonSlot?.isEmpty == true ? nil : persistentDaemonSlot, - skipDaemonBootstrap: skipDaemonBootstrap + do { + let endpoint = try controller.startPTYBridge( + sessionID: sessionID, + attachmentID: attachmentID, + command: command?.isEmpty == true ? nil : command, + requireExisting: requireExisting, + waitForReady: waitForReady, + timeout: waitForReady ? 90.0 : max(0.1, controllerDeadline.timeIntervalSinceNow) ) - workspace.configureRemoteConnection(config, autoConnect: autoConnect) - notifyRemotePTYControllerAvailabilityChanged() - - let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ - "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), - "remote": workspace.remoteStatusPayload(), + var payload = v2RemotePTYTargetPayload(target) + payload["host"] = endpoint.host + payload["port"] = endpoint.port + payload["token"] = endpoint.token + payload["session_id"] = endpoint.sessionID + payload["attachment_id"] = endpoint.attachmentID + return .ok(payload) + } catch { + return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, + "attachment_id": attachmentID, ]) } - - return result } - private func v2WorkspaceRemoteDisconnect(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + private nonisolated func v2WorkspaceRemotePTYResize(params: [String: Any]) -> V2CallResult { + let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) + if let error = workspaceSelection.error { return error } + guard let sessionID = v2RawString(params, "session_id")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty else { + return .err(code: "invalid_params", message: "Missing session_id", data: nil) } - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) + guard let attachmentID = v2RawString(params, "attachment_id")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !attachmentID.isEmpty else { + return .err(code: "invalid_params", message: "Missing attachment_id", data: nil) } + guard let attachmentToken = v2RawString(params, "attachment_token")? + .trimmingCharacters(in: .whitespacesAndNewlines), + !attachmentToken.isEmpty else { + return .err(code: "invalid_params", message: "Missing attachment_token", data: nil) + } + guard let cols = v2StrictInt(params, "cols"), cols > 0, + let rows = v2StrictInt(params, "rows"), rows > 0 else { + return .err(code: "invalid_params", message: "cols and rows must be positive integers", data: nil) + } + let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) + if let error = surfaceSelection.error { return error } - let clearConfiguration = v2Bool(params, "clear") ?? false - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - - // Must run on main for v2MainSync because disconnect mutates TabManager/UI-owned workspace state. - v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return - } - - workspace.disconnectRemoteConnection(clearConfiguration: clearConfiguration) - let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ - "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), - "remote": workspace.remoteStatusPayload(), + let resolved = v2ResolveRemotePTYTarget( + params: params, + requestedWorkspaceId: workspaceSelection.workspaceId, + preferredSurfaceId: surfaceSelection.surfaceId + ) + if let error = resolved.error { return error } + guard let target = resolved.target else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + guard let controller = target.controller else { + return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, + "attachment_id": attachmentID, ]) } - return result + do { + try controller.resizePTY( + sessionID: sessionID, + attachmentID: attachmentID, + attachmentToken: attachmentToken, + cols: cols, + rows: rows + ) + var payload = v2RemotePTYTargetPayload(target) + payload["session_id"] = sessionID + payload["attachment_id"] = attachmentID + payload["attachment_token"] = attachmentToken + payload["cols"] = cols + payload["rows"] = rows + payload["resized"] = true + return .ok(payload) + } catch { + return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ + "workspace_id": target.workspaceId.uuidString, + "workspace_ref": target.workspaceRef, + "session_id": sessionID, + "attachment_id": attachmentID, + ]) + } } - private func v2WorkspaceRemoteReconnect(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) - } - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - // Must run on main for v2MainSync because reconnect mutates TabManager/UI-owned workspace state. - v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return - } - guard workspace.remoteConfiguration != nil else { - result = .err(code: "invalid_state", message: "Remote workspace is not configured", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) - return - } - workspace.reconnectRemoteConnection() - notifyRemotePTYControllerAvailabilityChanged() - let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ - "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), - "remote": workspace.remoteStatusPayload(), - ]) - } - return result - } + @MainActor - private func v2WorkspaceRemoteForegroundAuthReady(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) + private func v2WorkspaceAction(params: [String: Any]) -> V2CallResult { + guard let tabManager = v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) } - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", 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", + "set_description", "clear_description", + "move_up", "move_down", "move_top", + "close_others", "close_above", "close_below", + "mark_read", "mark_unread", + "set_color", "clear_color" + ] - let foregroundAuthToken = v2RawString(params, "foreground_auth_token")? - .trimmingCharacters(in: .whitespacesAndNewlines) - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), + var result: V2CallResult = .err(code: "invalid_params", message: "Unknown workspace action", data: [ + "action": action, + "supported_actions": supportedActions ]) - // Must run on main for v2MainSync because this may arm a pending connect or start reconnecting immediately. v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { + 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 } - workspace.notifyRemoteForegroundAuthenticationReady(token: foregroundAuthToken) - notifyRemotePTYControllerAvailabilityChanged() - let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ - "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), - "remote": workspace.remoteStatusPayload(), - ]) - } - - return result - } - - private func v2WorkspaceRemoteStatus(params: [String: Any]) -> V2CallResult { - let requestedWorkspaceId = v2UUID(params, "workspace_id") - if v2HasNonNullParam(params, "workspace_id"), requestedWorkspaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) - } - let fallbackTabManager = v2ResolveTabManager(params: params) - let workspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - guard let workspaceId else { - return .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - } + let windowId = v2ResolveWindowId(tabManager: tabManager) - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - ]) + @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 + } - // Must run on main for v2MainSync because Workspace.remoteStatusPayload reads TabManager/UI-owned state. - v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return + @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) } - let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ - "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), - "remote": workspace.remoteStatusPayload(), - ]) - } - return result - } - - private nonisolated func v2RequestedRemotePTYWorkspaceID(params: [String: Any]) -> ( - workspaceId: UUID?, - error: V2CallResult? - ) { - var workspaceId: UUID? - var invalidWorkspaceID = false - v2MainSync { - v2RefreshKnownRefs() - workspaceId = v2UUID(params, "workspace_id") - invalidWorkspaceID = v2HasNonNullParam(params, "workspace_id") && workspaceId == nil - } - if invalidWorkspaceID { - return ( - nil, - .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) - ) - } - return (workspaceId, nil) - } - - private nonisolated func v2RequestedRemotePTYSurfaceID(params: [String: Any]) -> ( - surfaceId: UUID?, - error: V2CallResult? - ) { - var surfaceId: UUID? - var invalidSurfaceID = false - v2MainSync { - v2RefreshKnownRefs() - surfaceId = v2UUID(params, "surface_id") - invalidSurfaceID = v2HasNonNullParam(params, "surface_id") && surfaceId == nil - } - if invalidSurfaceID { - return ( - nil, - .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) - ) - } - return (surfaceId, nil) - } - - private nonisolated func v2ResolveRemotePTYTarget( - params: [String: Any], - requestedWorkspaceId: UUID?, - preferredSurfaceId: UUID? = nil - ) -> (target: RemotePTYSocketTarget?, error: V2CallResult?) { - if v2HasNonNullParam(params, "allow_moved_surface"), - v2Bool(params, "allow_moved_surface") == nil { - return ( - nil, - .err(code: "invalid_params", message: "Missing or invalid allow_moved_surface", data: nil) - ) - } - let allowMovedSurface = v2Bool(params, "allow_moved_surface") ?? false - let requestedSessionID = v2RawString(params, "session_id").flatMap { raw -> String? in - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - var resolvedWorkspaceId: UUID? - var target: RemotePTYSocketTarget? - var workspaceMismatchData: [String: Any]? - - v2MainSync { - v2RefreshKnownRefs() - let fallbackTabManager = v2ResolveTabManager(params: params) - let fallbackWorkspaceId = requestedWorkspaceId ?? fallbackTabManager?.selectedTabId - var owner: TabManager? - var workspace: Workspace? - if let preferredSurfaceId { - if let fallbackTabManager, - let surfaceWorkspace = fallbackTabManager.tabs.first(where: { - $0.panels[preferredSurfaceId] != nil - && $0.surfaceIdFromPanelId(preferredSurfaceId) != nil - }) { - owner = fallbackTabManager - workspace = surfaceWorkspace - } else if let located = AppDelegate.shared?.workspaceContainingPanel( - panelId: preferredSurfaceId, - preferredWorkspaceId: fallbackWorkspaceId - ) { - owner = located.tabManager - workspace = located.workspace - } - } - if workspace == nil, - let fallbackWorkspaceId, - let fallbackOwner = AppDelegate.shared?.tabManagerFor(tabId: fallbackWorkspaceId), - let fallbackWorkspace = fallbackOwner.tabs.first(where: { $0.id == fallbackWorkspaceId }) { - owner = fallbackOwner - workspace = fallbackWorkspace - } - resolvedWorkspaceId = workspace?.id ?? fallbackWorkspaceId - guard let owner, let workspace else { - return - } - if let requestedWorkspaceId, - workspace.id != requestedWorkspaceId { - let matchedMovedSurface = allowMovedSurface - && preferredSurfaceId.map { - workspace.remotePTYSessionIDMatches(panelId: $0, sessionID: requestedSessionID) - } == true - guard matchedMovedSurface else { - workspaceMismatchData = [ - "workspace_id": requestedWorkspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: requestedWorkspaceId), - "surface_id": v2OrNull(preferredSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: preferredSurfaceId), - "resolved_workspace_id": workspace.id.uuidString, - "resolved_workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id), - ] - return - } - } - - let windowId = v2ResolveWindowId(tabManager: owner) - target = RemotePTYSocketTarget( - controller: workspace.remotePTYSessionControllerForSocketCommand(), - windowId: windowId, - windowRef: v2Ref(kind: .window, uuid: windowId), - workspaceId: workspace.id, - workspaceRef: v2Ref(kind: .workspace, uuid: workspace.id), - workspaceTitle: workspace.title - ) - } - - if let workspaceMismatchData { - return ( - nil, - .err( - code: "invalid_params", - message: "surface_id does not belong to workspace_id", - data: workspaceMismatchData - ) - ) - } - guard let resolvedWorkspaceId else { - return ( - nil, - .err(code: "invalid_params", message: "Missing workspace_id", data: nil) - ) - } - guard let target else { - return ( - nil, - .err( - code: "not_found", - message: "Workspace not found", - data: v2RemotePTYWorkspaceData(workspaceId: resolvedWorkspaceId) - ) - ) - } - return (target, nil) - } - - nonisolated func notifyRemotePTYControllerAvailabilityChanged() { - remotePTYControllerAvailabilityCondition.lock() - remotePTYControllerAvailabilityGeneration &+= 1 - remotePTYControllerAvailabilityCondition.broadcast() - remotePTYControllerAvailabilityCondition.unlock() - } - - private nonisolated func v2ResolveRemotePTYTargetWaitingForController( - params: [String: Any], - requestedWorkspaceId: UUID?, - preferredSurfaceId: UUID?, - deadline: Date - ) -> (target: RemotePTYSocketTarget?, error: V2CallResult?) { - var observedGeneration: UInt64? - - while true { - let resolved = v2ResolveRemotePTYTarget( - params: params, - requestedWorkspaceId: requestedWorkspaceId, - preferredSurfaceId: preferredSurfaceId - ) - if let error = resolved.error { - return (nil, error) - } - guard let target = resolved.target else { - return resolved - } - if target.controller != nil || Date() >= deadline { - return (target, nil) - } - - remotePTYControllerAvailabilityCondition.lock() - let currentGeneration = remotePTYControllerAvailabilityGeneration - guard let previousGeneration = observedGeneration else { - observedGeneration = currentGeneration - remotePTYControllerAvailabilityCondition.unlock() - continue - } - if previousGeneration != currentGeneration { - observedGeneration = currentGeneration - remotePTYControllerAvailabilityCondition.unlock() - continue - } - _ = remotePTYControllerAvailabilityCondition.wait(until: deadline) - observedGeneration = remotePTYControllerAvailabilityGeneration - remotePTYControllerAvailabilityCondition.unlock() - } - } - - private nonisolated func v2RemotePTYWorkspaceData(workspaceId: UUID) -> [String: Any] { - var workspaceRef: Any = NSNull() - v2MainSync { - workspaceRef = v2Ref(kind: .workspace, uuid: workspaceId) - } - return [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": workspaceRef, - ] - } - - private nonisolated func v2RemotePTYTargetPayload(_ target: RemotePTYSocketTarget) -> [String: Any] { - [ - "window_id": v2OrNull(target.windowId?.uuidString), - "window_ref": target.windowRef, - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "workspace_title": target.workspaceTitle, - ] - } - - private nonisolated func v2WorkspaceRemotePTYSessions(params: [String: Any]) -> V2CallResult { - if v2HasNonNullParam(params, "all_workspaces"), v2Bool(params, "all_workspaces") == nil { - return .err(code: "invalid_params", message: "Missing or invalid all_workspaces", data: nil) - } - let allWorkspaces = v2Bool(params, "all_workspaces") ?? false - let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) - if let error = workspaceSelection.error { return error } - let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) - if let error = surfaceSelection.error { return error } - let requestedWorkspaceId = workspaceSelection.workspaceId - if allWorkspaces, requestedWorkspaceId != nil { - return .err(code: "invalid_params", message: "all_workspaces cannot be combined with workspace_id", data: nil) - } - if allWorkspaces { - var targets: [RemotePTYSocketTarget] = [] - v2MainSync { - v2RefreshKnownRefs() - guard let app = AppDelegate.shared else { return } - for summary in app.listMainWindowSummaries() { - guard let owner = app.tabManagerFor(windowId: summary.windowId) else { continue } - for workspace in owner.tabs where workspace.isRemoteWorkspace { - targets.append( - RemotePTYSocketTarget( - controller: workspace.remotePTYSessionControllerForSocketCommand(), - windowId: summary.windowId, - windowRef: v2Ref(kind: .window, uuid: summary.windowId), - workspaceId: workspace.id, - workspaceRef: v2Ref(kind: .workspace, uuid: workspace.id), - workspaceTitle: workspace.title - ) - ) - } - } - } - - var sessions: [[String: Any]] = [] - var errors: [[String: Any]] = [] - for target in targets { - guard let controller = target.controller else { - var payload = v2RemotePTYTargetPayload(target) - payload["error"] = "remote connection is not active" - errors.append(payload) - continue - } - do { - let workspaceSessions = try controller.listPTYSessions() - sessions.append(contentsOf: workspaceSessions.map { - v2RemotePTYSessionPayload($0, target: target) - }) - } catch { - var payload = v2RemotePTYTargetPayload(target) - payload["error"] = v2RemotePTYUserFacingErrorMessage(error) - errors.append(payload) - } - } - - return .ok([ - "all_workspaces": true, - "workspace_count": targets.count, - "sessions": sessions, - "errors": errors, - ]) - } - - let resolved = v2ResolveRemotePTYTarget( - params: params, - requestedWorkspaceId: requestedWorkspaceId, - preferredSurfaceId: surfaceSelection.surfaceId - ) - if let error = resolved.error { return error } - guard let target = resolved.target else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - guard let controller = target.controller else { - return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - ]) - } - - do { - let sessions = try controller.listPTYSessions() - var payload = v2RemotePTYTargetPayload(target) - payload["sessions"] = sessions.map { v2RemotePTYSessionPayload($0, target: target) } - return .ok(payload) - } catch { - return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - ]) - } - } - - private nonisolated func v2RemotePTYSessionPayload( - _ session: [String: Any], - target: RemotePTYSocketTarget - ) -> [String: Any] { - var payload = session - payload["window_id"] = v2OrNull(target.windowId?.uuidString) - payload["window_ref"] = target.windowRef - payload["workspace_id"] = target.workspaceId.uuidString - payload["workspace_ref"] = target.workspaceRef - payload["workspace_title"] = target.workspaceTitle - return payload - } - - private nonisolated func v2WorkspaceRemotePTYClose(params: [String: Any]) -> V2CallResult { - let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) - if let error = workspaceSelection.error { return error } - guard let sessionID = v2RawString(params, "session_id")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !sessionID.isEmpty else { - return .err(code: "invalid_params", message: "Missing session_id", data: nil) - } - let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) - if let error = surfaceSelection.error { return error } - - let resolved = v2ResolveRemotePTYTarget( - params: params, - requestedWorkspaceId: workspaceSelection.workspaceId, - preferredSurfaceId: surfaceSelection.surfaceId - ) - if let error = resolved.error { return error } - guard let target = resolved.target else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - guard let controller = target.controller else { - return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - ]) - } - - do { - try controller.closePTYSession(sessionID: sessionID) - var payload = v2RemotePTYTargetPayload(target) - payload["session_id"] = sessionID - payload["closed"] = true - return .ok(payload) - } catch { - return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - ]) - } - } - - private nonisolated func v2WorkspaceRemotePTYDetach(params: [String: Any]) -> V2CallResult { - let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) - if let error = workspaceSelection.error { return error } - guard let sessionID = v2RawString(params, "session_id")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !sessionID.isEmpty else { - return .err(code: "invalid_params", message: "Missing session_id", data: nil) - } - guard let attachmentID = v2RawString(params, "attachment_id")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !attachmentID.isEmpty else { - return .err(code: "invalid_params", message: "Missing attachment_id", data: nil) - } - guard let attachmentToken = v2RawString(params, "attachment_token")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !attachmentToken.isEmpty else { - return .err(code: "invalid_params", message: "Missing attachment_token", data: nil) - } - let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) - if let error = surfaceSelection.error { return error } - - let resolved = v2ResolveRemotePTYTarget( - params: params, - requestedWorkspaceId: workspaceSelection.workspaceId, - preferredSurfaceId: surfaceSelection.surfaceId - ) - if let error = resolved.error { return error } - guard let target = resolved.target else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - guard let controller = target.controller else { - return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - "attachment_id": attachmentID, - ]) - } - - do { - try controller.detachPTYSession( - sessionID: sessionID, - attachmentID: attachmentID, - attachmentToken: attachmentToken - ) - var payload = v2RemotePTYTargetPayload(target) - payload["session_id"] = sessionID - payload["attachment_id"] = attachmentID - payload["detached"] = true - return .ok(payload) - } catch { - return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - "attachment_id": attachmentID, - ]) - } - } - - private nonisolated func v2WorkspaceRemotePTYBridge(params: [String: Any]) -> V2CallResult { - let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) - if let error = workspaceSelection.error { return error } - guard let sessionID = v2RawString(params, "session_id")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !sessionID.isEmpty else { - return .err(code: "invalid_params", message: "Missing session_id", data: nil) - } - let attachmentID = (v2RawString(params, "attachment_id")? - .trimmingCharacters(in: .whitespacesAndNewlines)) - .flatMap { $0.isEmpty ? nil : $0 } - ?? UUID().uuidString.lowercased() - let command = v2RawString(params, "command")? - .trimmingCharacters(in: .whitespacesAndNewlines) - let requireExisting = v2Bool(params, "require_existing") ?? false - let waitForReady = v2Bool(params, "wait_for_ready") ?? false - let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) - if let error = surfaceSelection.error { return error } - let preferredSurfaceId = surfaceSelection.surfaceId ?? UUID(uuidString: attachmentID) - - let controllerDeadline = Date().addingTimeInterval(waitForReady ? 90.0 : 8.0) - let resolved = waitForReady - ? v2ResolveRemotePTYTargetWaitingForController( - params: params, - requestedWorkspaceId: workspaceSelection.workspaceId, - preferredSurfaceId: preferredSurfaceId, - deadline: controllerDeadline - ) - : v2ResolveRemotePTYTarget( - params: params, - requestedWorkspaceId: workspaceSelection.workspaceId, - preferredSurfaceId: preferredSurfaceId - ) - if let error = resolved.error { return error } - guard let target = resolved.target else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - guard let controller = target.controller else { - return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - "attachment_id": attachmentID, - ]) - } - - do { - let endpoint = try controller.startPTYBridge( - sessionID: sessionID, - attachmentID: attachmentID, - command: command?.isEmpty == true ? nil : command, - requireExisting: requireExisting, - waitForReady: waitForReady, - timeout: waitForReady ? 90.0 : max(0.1, controllerDeadline.timeIntervalSinceNow) - ) - var payload = v2RemotePTYTargetPayload(target) - payload["host"] = endpoint.host - payload["port"] = endpoint.port - payload["token"] = endpoint.token - payload["session_id"] = endpoint.sessionID - payload["attachment_id"] = endpoint.attachmentID - return .ok(payload) - } catch { - return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - "attachment_id": attachmentID, - ]) - } - } - - private nonisolated func v2WorkspaceRemotePTYResize(params: [String: Any]) -> V2CallResult { - let workspaceSelection = v2RequestedRemotePTYWorkspaceID(params: params) - if let error = workspaceSelection.error { return error } - guard let sessionID = v2RawString(params, "session_id")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !sessionID.isEmpty else { - return .err(code: "invalid_params", message: "Missing session_id", data: nil) - } - guard let attachmentID = v2RawString(params, "attachment_id")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !attachmentID.isEmpty else { - return .err(code: "invalid_params", message: "Missing attachment_id", data: nil) - } - guard let attachmentToken = v2RawString(params, "attachment_token")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !attachmentToken.isEmpty else { - return .err(code: "invalid_params", message: "Missing attachment_token", data: nil) - } - guard let cols = v2StrictInt(params, "cols"), cols > 0, - let rows = v2StrictInt(params, "rows"), rows > 0 else { - return .err(code: "invalid_params", message: "cols and rows must be positive integers", data: nil) - } - let surfaceSelection = v2RequestedRemotePTYSurfaceID(params: params) - if let error = surfaceSelection.error { return error } - - let resolved = v2ResolveRemotePTYTarget( - params: params, - requestedWorkspaceId: workspaceSelection.workspaceId, - preferredSurfaceId: surfaceSelection.surfaceId - ) - if let error = resolved.error { return error } - guard let target = resolved.target else { - return .err(code: "not_found", message: "Workspace not found", data: nil) - } - guard let controller = target.controller else { - return .err(code: "remote_pty_error", message: "remote connection is not active", data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - "attachment_id": attachmentID, - ]) - } - - do { - try controller.resizePTY( - sessionID: sessionID, - attachmentID: attachmentID, - attachmentToken: attachmentToken, - cols: cols, - rows: rows - ) - var payload = v2RemotePTYTargetPayload(target) - payload["session_id"] = sessionID - payload["attachment_id"] = attachmentID - payload["attachment_token"] = attachmentToken - payload["cols"] = cols - payload["rows"] = rows - payload["resized"] = true - return .ok(payload) - } catch { - return .err(code: "remote_pty_error", message: v2RemotePTYUserFacingErrorMessage(error), data: [ - "workspace_id": target.workspaceId.uuidString, - "workspace_ref": target.workspaceRef, - "session_id": sessionID, - "attachment_id": attachmentID, - ]) - } - } - - private func v2WorkspaceRemotePTYAttachEnd(params: [String: Any]) -> V2CallResult { - guard let workspaceId = 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) - } - guard let sessionID = v2RawString(params, "session_id")? - .trimmingCharacters(in: .whitespacesAndNewlines), - !sessionID.isEmpty else { - return .err(code: "invalid_params", message: "Missing session_id", data: nil) - } - - var result: V2CallResult = .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "session_id": sessionID, - "workspace_found": false, - "cleared_remote_pty_session": false, - "untracked_remote_terminal": false, - ]) - - v2MainSync { - v2RefreshKnownRefs() - let located = AppDelegate.shared?.workspaceContainingPanel( - panelId: surfaceId, - preferredWorkspaceId: workspaceId - ) - let fallbackOwner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) - let fallbackWorkspace = fallbackOwner?.tabs.first(where: { $0.id == workspaceId }) - guard let owner = located?.tabManager ?? fallbackOwner, - let workspace = located?.workspace ?? fallbackWorkspace else { - return - } - let outcome = workspace.markRemotePTYAttachEnded( - surfaceId: surfaceId, - sessionID: sessionID - ) - let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ - "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), - "session_id": sessionID, - "workspace_found": true, - "cleared_remote_pty_session": outcome.clearedRemotePTYSession, - "untracked_remote_terminal": outcome.untrackedRemoteTerminal, - "remote": workspace.remoteStatusPayload(), - ]) - } - - return result - } - - private func v2WorkspaceRemoteTerminalSessionEnd(params: [String: Any]) -> V2CallResult { - guard let workspaceId = 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) - } - guard let relayPort = v2StrictInt(params, "relay_port"), - relayPort > 0, - relayPort <= 65535 else { - return .err(code: "invalid_params", message: "Missing or invalid relay_port", data: nil) - } - - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "relay_port": relayPort, - ]) - - v2MainSync { - guard let owner = AppDelegate.shared?.tabManagerFor(tabId: workspaceId), - let workspace = owner.tabs.first(where: { $0.id == workspaceId }) else { - return - } - workspace.markRemoteTerminalSessionEnded(surfaceId: surfaceId, relayPort: relayPort) - let windowId = v2ResolveWindowId(tabManager: owner) - result = .ok([ - "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), - "relay_port": relayPort, - "remote": workspace.remoteStatusPayload(), - ]) - } - - return result - } - - private func v2SurfaceReportTTY(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) - } - let requestedSurfaceId = v2UUID(params, "surface_id") - if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) - } - guard let ttyName = v2RawString(params, "tty_name")?.trimmingCharacters(in: .whitespacesAndNewlines), - !ttyName.isEmpty else { - return .err(code: "invalid_params", message: "Missing tty_name", data: nil) - } - - var result: V2CallResult = .err( - code: "not_found", - message: "Workspace not found", - data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": v2OrNull(requestedSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - ] - ) - - v2MainSync { - guard let tab = self.tabForSidebarMutation(id: workspaceId) else { - return - } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let surfaceId = self.resolveReportedSurfaceId( - in: tab, - requestedSurfaceId: requestedSurfaceId, - validSurfaceIds: validSurfaceIds - ) - guard let surfaceId, validSurfaceIds.contains(surfaceId) else { - if tab.isRemoteWorkspace, validSurfaceIds.isEmpty { - tab.rememberPendingRemoteSurfaceTTY(ttyName, requestedSurfaceId: requestedSurfaceId) - result = .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": v2OrNull(requestedSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - "tty_name": ttyName, - "pending": true, - ]) - return - } - result = .err( - code: "not_found", - message: "Surface not found", - data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": v2OrNull(requestedSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - ] - ) - return - } - - tab.surfaceTTYNames[surfaceId] = ttyName - if tab.isRemoteWorkspace { - tab.syncRemotePortScanTTYs() - _ = tab.applyPendingRemoteSurfacePortKickIfNeeded(to: surfaceId) - } else { - PortScanner.shared.registerTTY(workspaceId: workspaceId, panelId: surfaceId, ttyName: ttyName) - } - - result = .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "tty_name": ttyName, - ]) - } - - return result - } - - private func v2SurfaceReportShellState(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) - } - let requestedSurfaceId = v2UUID(params, "surface_id") - if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) - } - let rawState = v2RawString(params, "state") - ?? v2RawString(params, "shell_state") - ?? v2RawString(params, "activity") - guard let rawState, - let state = Self.parseReportedShellActivityState(rawState) else { - return .err(code: "invalid_params", message: "state must be prompt, running, or unknown", data: nil) - } - - if let requestedSurfaceId { - let shouldPublish = socketFastPathState.shouldPublishShellActivity( - workspaceId: workspaceId, - panelId: requestedSurfaceId, - state: state.rawValue - ) - if shouldPublish { - DispatchQueue.main.async { - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: workspaceId) else { return } - tabManager.updateSurfaceShellActivity( - tabId: workspaceId, - surfaceId: requestedSurfaceId, - state: state - ) - } - } - return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": requestedSurfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - "state": state.rawValue, - "published": shouldPublish, - ]) - } - - DispatchQueue.main.async { [weak self] in - guard let self else { return } - guard let tab = self.tabForSidebarMutation(id: workspaceId) else { - return - } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let surfaceId = self.resolveReportedSurfaceId( - in: tab, - requestedSurfaceId: requestedSurfaceId, - validSurfaceIds: validSurfaceIds - ) - guard let surfaceId, validSurfaceIds.contains(surfaceId) else { - return - } - - guard let tabManager = AppDelegate.shared?.tabManagerFor(tabId: tab.id) else { - return - } - tabManager.updateSurfaceShellActivity(tabId: tab.id, surfaceId: surfaceId, state: state) - } - - return .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": NSNull(), - "surface_ref": NSNull(), - "state": state.rawValue, - "published": true, - "pending": true, - ]) - } - - private func v2SurfacePortsKick(params: [String: Any]) -> V2CallResult { - guard let workspaceId = v2UUID(params, "workspace_id") else { - return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) - } - let requestedSurfaceId = v2UUID(params, "surface_id") - if v2HasNonNullParam(params, "surface_id"), requestedSurfaceId == nil { - return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) - } - let reason: WorkspaceRemoteSessionController.PortScanKickReason - if let rawReason = v2RawString(params, "reason") { - guard let parsedReason = Self.parseRemotePortScanKickReason(rawReason) else { - return .err( - code: "invalid_params", - message: "reason must be command or refresh", - data: nil - ) - } - reason = parsedReason - } else { - reason = .command - } - - var result: V2CallResult = .err( - code: "not_found", - message: "Workspace not found", - data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": v2OrNull(requestedSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - ] - ) - - v2MainSync { - guard let tab = self.tabForSidebarMutation(id: workspaceId) else { - return - } - let validSurfaceIds = Set(tab.panels.keys) - tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds) - - let surfaceId = self.resolveReportedSurfaceId( - in: tab, - requestedSurfaceId: requestedSurfaceId, - validSurfaceIds: validSurfaceIds - ) - guard let surfaceId, validSurfaceIds.contains(surfaceId) else { - if tab.isRemoteWorkspace, validSurfaceIds.isEmpty { - tab.rememberPendingRemoteSurfacePortKick( - reason: reason, - requestedSurfaceId: requestedSurfaceId - ) - result = .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": v2OrNull(requestedSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - "reason": reason.rawValue, - "pending": true, - ]) - return - } - result = .err( - code: "not_found", - message: "Surface not found", - data: [ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": v2OrNull(requestedSurfaceId?.uuidString), - "surface_ref": v2Ref(kind: .surface, uuid: requestedSurfaceId), - ] - ) - return - } - - if tab.isRemoteWorkspace { - tab.kickRemotePortScan(panelId: surfaceId, reason: reason) - } else { - PortScanner.shared.kick(workspaceId: workspaceId, panelId: surfaceId) - } - - result = .ok([ - "workspace_id": workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "reason": reason.rawValue, - ]) - } - - return result - } - - @MainActor - private func resolveReportedSurfaceId( - in workspace: Workspace, - requestedSurfaceId: UUID?, - validSurfaceIds: Set - ) -> UUID? { - if let requestedSurfaceId { - guard validSurfaceIds.contains(requestedSurfaceId) else { return nil } - return requestedSurfaceId - } - - if let focusedSurfaceId = workspace.focusedPanelId, - validSurfaceIds.contains(focusedSurfaceId), - (!workspace.isRemoteWorkspace || workspace.isRemoteTerminalSurface(focusedSurfaceId)) { - return focusedSurfaceId - } - - guard workspace.isRemoteWorkspace else { return nil } - - let remoteTerminalSurfaceIds = validSurfaceIds.filter { workspace.isRemoteTerminalSurface($0) } - if remoteTerminalSurfaceIds.count == 1 { - return remoteTerminalSurfaceIds.first - } - - if validSurfaceIds.count == 1 { - return validSurfaceIds.first - } - - return nil - } - - 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", - "set_description", "clear_description", - "move_up", "move_down", "move_top", - "close_others", "close_above", "close_below", - "mark_read", "mark_unread", - "set_color", "clear_color" - ] - - 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 "set_description": - guard let descriptionRaw = v2String(params, "description"), - !descriptionRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - result = .err(code: "invalid_params", message: "Missing or invalid description", data: nil) - return - } - tabManager.setCustomDescription(tabId: workspace.id, description: descriptionRaw) - finish(["description": v2OrNull(workspace.customDescription)]) - - case "clear_description": - tabManager.clearCustomDescription(tabId: workspace.id) - finish(["description": NSNull()]) - - 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() - - case "set_color": - guard let colorRaw = v2String(params, "color"), - !colorRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - result = .err(code: "invalid_params", message: "Missing or invalid color", data: nil) - return - } - let colorInput = colorRaw.trimmingCharacters(in: .whitespacesAndNewlines) - // Resolve named colors from the effective palette, including file-defined additions. - let effectivePalette = WorkspaceTabColorSettings.palette() - let hex: String - if let entry = effectivePalette.first(where: { - $0.name.caseInsensitiveCompare(colorInput) == .orderedSame - }) { - hex = entry.hex - } else if let normalized = WorkspaceTabColorSettings.normalizedHex(colorInput) { - hex = normalized - } else { - let colorNames = effectivePalette.map(\.name) - result = .err(code: "invalid_params", message: "Invalid color. Use a hex value (#RRGGBB) or a named color.", data: [ - "named_colors": colorNames - ]) - return - } - tabManager.setTabColor(tabId: workspace.id, color: hex) - finish(["color": hex]) - - case "clear_color": - tabManager.setTabColor(tabId: workspace.id, color: nil) - finish(["color": NSNull()]) - - 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", "move_to_new_workspace", "detach_to_workspace", "detach_to_new_workspace", - "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 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) - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - - @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.requestCloseTabRecordingHistory(tabId, 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": - workspace.markPanelRead(surfaceId) - finish() - - case "mark_unread", "mark_as_unread": - workspace.markPanelUnread(surfaceId) - finish() - - case "move_to_new_workspace", "detach_to_workspace", "detach_to_new_workspace": - result = v2MoveTabToNewWorkspaceActionResult(action: action, params: params, tabManager: tabManager, workspace: workspace, surfaceId: surfaceId) - 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 browserPanel = workspace.browserPanel(for: surfaceId) else { - result = .err(code: "invalid_state", message: "Duplicate is only available for browser tabs", data: nil) - return - } - guard BrowserAvailabilitySettings.isEnabled() else { - result = v2BrowserDisabledExternalOpenResult( - url: browserPanel.currentURLForTabDuplication, - tabManager: tabManager - ) - return - } - - guard let newPanel = workspace.duplicateBrowserToRight(panelId: surfaceId, focus: focus) else { - result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) - return - } - 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: focus) else { - result = .err(code: "internal_error", message: "Failed to create tab", data: nil) - return - } - _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) - 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 - } - guard BrowserAvailabilitySettings.isEnabled() else { - result = v2BrowserDisabledExternalOpenResult( - rawURL: urlRaw, - url: url, - tabManager: tabManager - ) - return - } - - let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) - guard let newPanel = workspace.newBrowserSurface( - inPane: paneId, - url: url, - focus: focus, - creationPolicy: .automationPreload - ) else { - result = .err(code: "internal_error", message: "Failed to create tab", data: nil) - return - } - _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) - 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 + switch action { + case "pin": + tabManager.setPinned(workspace, pinned: true) + finish(["pinned": true]) - @MainActor - @discardableResult - private func closeSurfaceRecordingHistory(in workspace: Workspace, surfaceId: UUID, force: Bool) -> Bool { - if let tabId = workspace.surfaceIdFromPanelId(surfaceId) { - return workspace.requestCloseTabRecordingHistory(tabId, force: force) - } + case "unpin": + tabManager.setPinned(workspace, pinned: false) + finish(["pinned": false]) - workspace.markCloseHistoryEligible(panelId: surfaceId) - return workspace.closePanel(surfaceId, force: force) - } + 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]) - 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, "terminal_id") - ?? v2UUID(params, "tab_id") { - return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) - } - if let paneId = v2UUID(params, "pane_id"), - let located = v2LocatePane(paneId) { - guard located.tabManager === tabManager else { return nil } - return located.workspace - } - guard let wsId = tabManager.selectedTabId else { return nil } - return tabManager.tabs.first(where: { $0.id == wsId }) - } + case "clear_name": + tabManager.clearCustomTitle(tabId: workspace.id) + finish(["title": workspace.title]) - private func v2SurfaceList(params: [String: Any]) -> V2CallResult { - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: "TabManager not available", data: nil) - } + case "set_description": + guard let descriptionRaw = v2String(params, "description"), + !descriptionRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + result = .err(code: "invalid_params", message: "Missing or invalid description", data: nil) + return + } + tabManager.setCustomDescription(tabId: workspace.id, description: descriptionRaw) + finish(["description": v2OrNull(workspace.customDescription)]) - var payload: [String: Any]? - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { return } + case "clear_description": + tabManager.clearCustomDescription(tabId: workspace.id) + finish(["description": NSNull()]) - // 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) + 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 }))]) - 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]) - ] - if let browserPanel = panel as? BrowserPanel { - item["developer_tools_visible"] = browserPanel.isDeveloperToolsVisible() - } - if let terminalPanel = panel as? TerminalPanel { - item["requested_working_directory"] = v2OrNull(v2NonEmptyString(terminalPanel.requestedWorkingDirectory)) - item["initial_command"] = v2OrNull(v2NonEmptyString(terminalPanel.surface.debugInitialCommand())) - item["tmux_start_command"] = v2OrNull(v2NonEmptyString(terminalPanel.surface.debugTmuxStartCommand())) - item["resume_binding"] = v2SurfaceResumeBindingPayload(ws.surfaceResumeBinding(panelId: panel.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 } - 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 v2SurfaceResumeSet(params: [String: Any]) -> V2CallResult { - if let error = v2SurfaceResumeTargetValidationError(params: params) { - return error - } - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: Self.v2WindowUnavailableMessage, data: nil) - } - guard let command = v2RawString(params, "command")?.trimmingCharacters(in: .whitespacesAndNewlines), - !command.isEmpty else { - return .err(code: "invalid_params", message: "Missing command", data: nil) - } - - let source = v2PublicSurfaceResumeSource(params) - let binding = SurfaceResumeBindingSnapshot( - name: v2OptionalTrimmedRawString(params, "name"), - kind: v2OptionalTrimmedRawString(params, "kind"), - command: command, - cwd: v2OptionalTrimmedRawString(params, "cwd"), - checkpointId: v2OptionalTrimmedRawString(params, "checkpoint_id") ?? v2OptionalTrimmedRawString(params, "checkpointId"), - source: source, - environment: v2StringMap(params, "environment"), - autoResume: source == "agent-hook" ? (v2Bool(params, "auto_resume") ?? false) : false, - updatedAt: Date().timeIntervalSince1970 - ) - - var result: V2CallResult = .err(code: "internal_error", message: "Failed to set resume binding", data: nil) - v2MainSync { - guard let target = v2ResolveSurfaceResumeTarget(params: params, fallbackTabManager: tabManager) else { - result = .err(code: "not_found", message: "Surface not found", data: nil) - return - } - let effectiveBinding = v2SurfaceResumeBindingWithApproval(binding) - guard target.workspace.setSurfaceResumeBinding(effectiveBinding, panelId: target.surfaceId) else { - result = .err(code: "invalid_params", message: "Resume command is empty", data: nil) - return - } - result = .ok(v2SurfaceResumeResult( - tabManager: target.tabManager, - workspace: target.workspace, - surfaceId: target.surfaceId, - binding: effectiveBinding, - cleared: false - )) - } - return result - } - - private func v2SurfaceResumeBindingWithApproval(_ binding: SurfaceResumeBindingSnapshot) -> SurfaceResumeBindingSnapshot { - let existingRecord = SurfaceResumeApprovalStore.matchingRecord(for: binding) - var effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) - if let promptlessCLIManualBinding = SurfaceResumeApprovalStore.applyingPromptlessCLIManualApprovalIfNeeded( - to: binding, - existingRecord: existingRecord - ) { - return promptlessCLIManualBinding - } - guard v2ShouldPromptForSurfaceResumeApproval(binding: binding, existingRecord: existingRecord) else { - return effectiveBinding - } - let policy = v2PromptForSurfaceResumeApproval(binding: effectiveBinding) - guard let record = SurfaceResumeApprovalStore.approve(binding: binding, policy: policy) else { - return effectiveBinding - } - effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) - effectiveBinding.approvalPolicy = record.policy - effectiveBinding.approvalRecordId = record.id - effectiveBinding.autoResume = record.policy == .auto - return effectiveBinding - } - - private func v2ShouldPromptForSurfaceResumeApproval( - binding: SurfaceResumeBindingSnapshot, - existingRecord: SurfaceResumeApprovalRecord? - ) -> Bool { - SurfaceResumeApprovalStore.shouldPromptForProposal( - binding: binding, - existingRecord: existingRecord, - isMainThread: Thread.isMainThread, - isRunningTests: ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil - ) - } - - private func v2PromptForSurfaceResumeApproval( - binding: SurfaceResumeBindingSnapshot - ) -> SurfaceResumeApprovalPolicy { - let alert = NSAlert() - alert.alertStyle = .informational - alert.messageText = String( - localized: "surfaceResumeApproval.proposal.title", - defaultValue: "Allow Resume Command?" - ) - let cwd = binding.cwd ?? String(localized: "surfaceResumeApproval.cwd.none", defaultValue: "None") - alert.informativeText = String( - format: String( - localized: "surfaceResumeApproval.proposal.message", - defaultValue: "A process wants cmux to keep this resume command for the current terminal:\n\n%@\n\nWorking directory: %@" - ), - binding.command, - cwd - ) - alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.auto", defaultValue: "Auto-Restore")) - alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.ask", defaultValue: "Ask Each Time")) - alert.addButton(withTitle: String(localized: "surfaceResumeApproval.proposal.manual", defaultValue: "Keep Manual")) - - switch alert.runModal() { - case .alertFirstButtonReturn: - return .auto - case .alertSecondButtonReturn: - return .prompt - default: - return .manual - } - } - - private func v2SurfaceResumeGet(params: [String: Any]) -> V2CallResult { - if let error = v2SurfaceResumeTargetValidationError(params: params) { - return error - } - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: Self.v2WindowUnavailableMessage, data: nil) - } + _ = tabManager.reorderWorkspace(tabId: workspace.id, toIndex: min(currentIndex + 1, tabManager.tabs.count - 1)) + finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let target = v2ResolveSurfaceResumeTarget(params: params, fallbackTabManager: tabManager) else { - result = .err(code: "not_found", message: "Surface not found", data: nil) - return - } - result = .ok(v2SurfaceResumeResult( - tabManager: target.tabManager, - workspace: target.workspace, - surfaceId: target.surfaceId, - binding: target.workspace.surfaceResumeBinding(panelId: target.surfaceId), - cleared: false - )) - } - return result - } + case "move_top": + tabManager.moveTabToTop(workspace.id) + finish(["index": v2OrNull(tabManager.tabs.firstIndex(where: { $0.id == workspace.id }))]) - private func v2SurfaceResumeClear(params: [String: Any]) -> V2CallResult { - if let error = v2SurfaceResumeTargetValidationError(params: params) { - return error - } - guard let tabManager = v2ResolveTabManager(params: params) else { - return .err(code: "unavailable", message: Self.v2WindowUnavailableMessage, data: nil) - } + case "close_others": + let candidates = tabManager.tabs.filter { $0.id != workspace.id && !$0.isPinned } + let closed = closeWorkspaces(candidates) + finish(["closed": closed]) - let expectedCheckpointId = v2OptionalTrimmedRawString(params, "checkpoint_id") - ?? v2OptionalTrimmedRawString(params, "checkpointId") - let expectedSource = v2OptionalTrimmedRawString(params, "source") - var result: V2CallResult = .err(code: "not_found", message: "Workspace not found", data: nil) - v2MainSync { - guard let target = v2ResolveSurfaceResumeTarget(params: params, fallbackTabManager: tabManager) else { - result = .err(code: "not_found", message: "Surface not found", data: nil) - return - } - let currentBinding = target.workspace.surfaceResumeBinding(panelId: target.surfaceId) - if let expectedCheckpointId, currentBinding?.checkpointId != expectedCheckpointId { - result = .ok(v2SurfaceResumeResult( - tabManager: target.tabManager, - workspace: target.workspace, - surfaceId: target.surfaceId, - binding: currentBinding, - cleared: false - )) - return - } - if let expectedSource, currentBinding?.source != expectedSource { - result = .ok(v2SurfaceResumeResult( - tabManager: target.tabManager, - workspace: target.workspace, - surfaceId: target.surfaceId, - binding: currentBinding, - cleared: false - )) - return - } - _ = target.workspace.clearSurfaceResumeBinding(panelId: target.surfaceId) - result = .ok(v2SurfaceResumeResult( - tabManager: target.tabManager, - workspace: target.workspace, - surfaceId: target.surfaceId, - binding: nil, - cleared: true - )) - } - return result - } + 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]) - private func v2PublicSurfaceResumeSource(_ params: [String: Any]) -> String? { - let source = v2OptionalTrimmedRawString(params, "source") - return source == "process-detected" ? "manual" : source - } + 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]) - private static let v2WindowUnavailableMessage = "cmux window is not available. Reopen the window and try again." + case "mark_read": + AppDelegate.shared?.notificationStore?.markRead(forTabId: workspace.id) + finish() - private func v2SurfaceResumeTargetValidationError(params: [String: Any]) -> V2CallResult? { - for key in ["window_id", "workspace_id", "surface_id", "tab_id"] { - if v2HasNonNullParam(params, key), v2UUID(params, key) == nil { - return .err(code: "invalid_params", message: "Missing or invalid \(key)", data: nil) - } - } - return nil - } + case "mark_unread": + AppDelegate.shared?.notificationStore?.markUnread(forTabId: workspace.id) + finish() - @MainActor - private func v2ResolveSurfaceResumeTarget( - params: [String: Any], - fallbackTabManager: TabManager - ) -> (tabManager: TabManager, workspace: Workspace, surfaceId: UUID)? { - if let explicitSurfaceId = v2UUID(params, "surface_id") ?? v2UUID(params, "tab_id") { - if let explicitWorkspaceId = v2UUID(params, "workspace_id") { - guard let workspace = fallbackTabManager.tabs.first(where: { $0.id == explicitWorkspaceId }), - workspace.terminalPanel(for: explicitSurfaceId) != nil else { - return nil + case "set_color": + guard let colorRaw = v2String(params, "color"), + !colorRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + result = .err(code: "invalid_params", message: "Missing or invalid color", data: nil) + return } - return (fallbackTabManager, workspace, explicitSurfaceId) - } - - if v2UUID(params, "window_id") != nil { - guard let workspace = fallbackTabManager.tabs.first(where: { - $0.terminalPanel(for: explicitSurfaceId) != nil - }) else { - return nil + let colorInput = colorRaw.trimmingCharacters(in: .whitespacesAndNewlines) + // Resolve named colors from the effective palette, including file-defined additions. + let effectivePalette = WorkspaceTabColorSettings.palette() + let hex: String + if let entry = effectivePalette.first(where: { + $0.name.caseInsensitiveCompare(colorInput) == .orderedSame + }) { + hex = entry.hex + } else if let normalized = WorkspaceTabColorSettings.normalizedHex(colorInput) { + hex = normalized + } else { + let colorNames = effectivePalette.map(\.name) + result = .err(code: "invalid_params", message: "Invalid color. Use a hex value (#RRGGBB) or a named color.", data: [ + "named_colors": colorNames + ]) + return } - return (fallbackTabManager, workspace, explicitSurfaceId) - } + tabManager.setTabColor(tabId: workspace.id, color: hex) + finish(["color": hex]) + + case "clear_color": + tabManager.setTabColor(tabId: workspace.id, color: nil) + finish(["color": NSNull()]) - if let located = AppDelegate.shared?.locateSurface(surfaceId: explicitSurfaceId), - let workspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }), - workspace.terminalPanel(for: explicitSurfaceId) != nil { - return (located.tabManager, workspace, explicitSurfaceId) - } - if let workspace = fallbackTabManager.tabs.first(where: { - $0.terminalPanel(for: explicitSurfaceId) != nil - }) { - return (fallbackTabManager, workspace, explicitSurfaceId) - } - if let workspace = v2ResolveWorkspace(params: params, tabManager: fallbackTabManager), - workspace.terminalPanel(for: explicitSurfaceId) != nil { - return (fallbackTabManager, workspace, explicitSurfaceId) + default: + result = .err(code: "invalid_params", message: "Unknown workspace action", data: [ + "action": action, + "supported_actions": supportedActions + ]) } - return nil - } - guard let workspace = v2ResolveWorkspace(params: params, tabManager: fallbackTabManager), - let surfaceId = workspace.focusedPanelId, - workspace.terminalPanel(for: surfaceId) != nil else { - return nil } - return (fallbackTabManager, workspace, surfaceId) - } - - private func v2SurfaceResumeResult( - tabManager: TabManager, - workspace: Workspace, - surfaceId: UUID, - binding: SurfaceResumeBindingSnapshot?, - cleared: Bool - ) -> [String: Any] { - let paneId = workspace.paneId(forPanelId: surfaceId)?.id - let windowId = v2ResolveWindowId(tabManager: tabManager) - return [ - "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), - "pane_id": v2OrNull(paneId?.uuidString), - "pane_ref": v2Ref(kind: .pane, uuid: paneId), - "surface_id": surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: surfaceId), - "cleared": cleared, - "resume_binding": v2SurfaceResumeBindingPayload(binding) - ] - } - private func v2SurfaceResumeBindingPayload(_ binding: SurfaceResumeBindingSnapshot?) -> Any { - guard let binding else { return NSNull() } - let effectiveBinding = SurfaceResumeApprovalStore.applyingStoredApproval(to: binding) - return [ - "name": v2OrNull(effectiveBinding.name), - "kind": v2OrNull(effectiveBinding.kind), - "command": effectiveBinding.command, - "cwd": v2OrNull(effectiveBinding.cwd), - "checkpoint_id": v2OrNull(effectiveBinding.checkpointId), - "source": v2OrNull(effectiveBinding.source), - "environment": v2OrNull(effectiveBinding.environment), - "auto_resume": effectiveBinding.allowsAutomaticResume, - "approval_policy": v2OrNull(effectiveBinding.approvalPolicy?.rawValue), - "approval_record_id": v2OrNull(effectiveBinding.approvalRecordId), - "updated_at": effectiveBinding.updatedAt - ] + return result } - private func v2SurfaceFocus(params: [String: Any]) -> V2CallResult { + 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 surfaceId = v2UUID(params, "surface_id") else { - return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + guard let action = v2ActionKey(params) else { + return .err(code: "invalid_params", message: "Missing action", data: nil) } - var result: V2CallResult = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) + let supportedActions = [ + "rename", "clear_name", + "close_left", "close_right", "close_others", + "new_terminal_right", "new_browser_right", + "reload", "duplicate", "move_to_new_workspace", "detach_to_workspace", "detach_to_new_workspace", + "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 ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { + guard let workspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else { result = .err(code: "not_found", message: "Workspace not found", data: nil) return } - if let windowId = v2ResolveWindowId(tabManager: tabManager) { - _ = AppDelegate.shared?.focusMainWindow(windowId: windowId) - setActiveTabManager(tabManager) + 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 } - // Make sure the workspace is selected so focus effects apply to the visible UI. - if tabManager.selectedTabId != ws.id { - tabManager.selectWorkspace(ws) + let windowId = v2ResolveWindowId(tabManager: tabManager) + let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) + + @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) } - guard ws.panels[surfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString]) - return + @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) } - 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 - } + @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.requestCloseTabRecordingHistory(tabId, force: true) { + closed += 1 + } + } + return (closed, skippedPinned) + } - private func v2AgentSessionOptions(params: [String: Any]) -> ( - providerID: AgentSessionProviderID, - rendererKind: AgentSessionRendererKind, - error: V2CallResult? - ) { - let providerRaw = v2String(params, "provider_id") ?? v2String(params, "provider") - let rendererRaw = v2String(params, "renderer_kind") ?? v2String(params, "renderer") + 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]) - let providerID: AgentSessionProviderID - if let providerRaw { - switch v2NormalizedToken(providerRaw) { - case "codex": - providerID = .codex - case "claude", "claudecode": - providerID = .claude - case "opencode": - providerID = .opencode - default: - return ( - .codex, - .react, - .err( - code: "invalid_params", - message: "Invalid provider (codex|claude|opencode)", - data: ["provider": providerRaw] - ) - ) - } - } else { - providerID = .codex - } + case "clear_name": + workspace.setPanelCustomTitle(panelId: surfaceId, title: nil) + finish() - let rendererKind: AgentSessionRendererKind - if let rendererRaw { - switch v2NormalizedToken(rendererRaw) { - case "react": - rendererKind = .react - case "solid": - rendererKind = .solid - default: - return ( - providerID, - .react, - .err( - code: "invalid_params", - message: "Invalid renderer (react|solid)", - data: ["renderer": rendererRaw] + 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": + workspace.markPanelRead(surfaceId) + finish() + + case "mark_unread", "mark_as_unread": + workspace.markPanelUnread(surfaceId) + finish() + + case "move_to_new_workspace", "detach_to_workspace", "detach_to_new_workspace": + result = v2MoveTabToNewWorkspaceActionResult(action: action, params: params, tabManager: tabManager, workspace: workspace, surfaceId: surfaceId) + 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 browserPanel = workspace.browserPanel(for: surfaceId) else { + result = .err(code: "invalid_state", message: "Duplicate is only available for browser tabs", data: nil) + return + } + guard BrowserAvailabilitySettings.isEnabled() else { + result = v2BrowserDisabledExternalOpenResult( + url: browserPanel.currentURLForTabDuplication, + tabManager: tabManager ) - ) - } - } else { - rendererKind = .react - } + return + } - return (providerID, rendererKind, nil) - } + guard let newPanel = workspace.duplicateBrowserToRight(panelId: surfaceId, focus: focus) else { + result = .err(code: "internal_error", message: "Failed to duplicate tab", data: nil) + return + } + 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) + ]) - 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) - } - let panelType = v2PanelType(params, "type") ?? .terminal - if panelType == .agentSession { - return .err( - code: "invalid_params", - message: "agent-session is only supported by surface.create", - data: ["type": panelType.rawValue] - ) - } - let urlStr = v2String(params, "url") - let url = urlStr.flatMap { URL(string: $0) } - let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") - let initialCommand = v2OptionalTrimmedRawString(params, "initial_command") - let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") - let remotePTYSessionID = v2OptionalTrimmedRawString(params, "remote_pty_session_id") - let startupEnvironment = v2TrimmedStringMap(params, keys: ["startup_environment", "initial_env"]) - let parsedInitialDivider = v2InitialDividerPosition(params) - if let error = parsedInitialDivider.error { - return error - } - let initialDividerPosition = parsedInitialDivider.value - if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { - return v2BrowserDisabledExternalOpenResult(rawURL: urlStr, url: url, tabManager: tabManager) - } + 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 + } - 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 - } - let requestedSurfaceId: UUID? = v2UUID(params, "surface_id") - let targetSurfaceId: UUID? - if let requestedSurfaceId { - guard ws.panels[requestedSurfaceId] != nil else { - result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": requestedSurfaceId.uuidString]) + let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) + guard let newPanel = workspace.newTerminalSurface(inPane: paneId, focus: focus) else { + result = .err(code: "internal_error", message: "Failed to create tab", data: nil) return } - targetSurfaceId = requestedSurfaceId - } else { - targetSurfaceId = ws.focusedPanelId - } - guard let targetSurfaceId, ws.panels[targetSurfaceId] != nil else { - result = .err(code: "not_found", message: "No focused surface", data: nil) - return - } + _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) + 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) + ]) - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + 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 focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - let orientation = direction.orientation - let insertFirst = direction.insertFirst - let newId: UUID? - if panelType == .browser { - newId = ws.newBrowserSplit( - from: targetSurfaceId, - orientation: orientation, - insertFirst: insertFirst, + 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 + } + guard BrowserAvailabilitySettings.isEnabled() else { + result = v2BrowserDisabledExternalOpenResult( + rawURL: urlRaw, + url: url, + tabManager: tabManager + ) + return + } + + let targetIndex = insertionIndexToRight(anchorTabId: anchorTabId, inPane: paneId) + guard let newPanel = workspace.newBrowserSurface( + inPane: paneId, url: url, focus: focus, - creationPolicy: .automationPreload, - initialDividerPosition: initialDividerPosition.map { CGFloat($0) } - )?.id - } else { - newId = tabManager.newSplit( - tabId: ws.id, - surfaceId: targetSurfaceId, - direction: direction, - focus: focus, - workingDirectory: workingDirectory, - initialCommand: initialCommand, - tmuxStartCommand: tmuxStartCommand, - startupEnvironment: startupEnvironment, - initialDividerPosition: initialDividerPosition.map { CGFloat($0) }, - remotePTYSessionID: remotePTYSessionID - ) - } - - if let newId { - 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) + creationPolicy: .automationPreload + ) else { + result = .err(code: "internal_error", message: "Failed to create tab", data: nil) + return + } + _ = workspace.reorderSurface(panelId: newPanel.id, toIndex: targetIndex, focus: focus) + 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) ]) - } else { - result = .err(code: "internal_error", message: "Failed to create split", data: nil) - } - } - return result - } - - private func v2SurfaceRespawn(params: [String: Any]) -> V2CallResult { - let fallbackTabManager = v2ResolveTabManager(params: params) - - let command = v2OptionalTrimmedRawString(params, "command") - ?? v2OptionalTrimmedRawString(params, "initial_command") - ?? "exec ${SHELL:-/bin/zsh} -l" - let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") ?? command - let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") - let focus: Bool? - if v2HasNonNullParam(params, "focus") { - guard let parsedFocus = v2Bool(params, "focus") else { - return .err( - code: "invalid_params", - message: String( - localized: "rpc.v2.surface.respawn.invalidFocus", - defaultValue: "Missing or invalid focus" - ), - data: nil - ) - } - focus = v2FocusAllowed(requested: parsedFocus) - } else { - focus = nil - } - var result: V2CallResult = .err( - code: "internal_error", - message: String( - localized: "rpc.v2.surface.respawn.failed", - defaultValue: "Failed to respawn surface" - ), - data: nil - ) - v2MainSync { - let ws: Workspace - let tabManager: TabManager - let surfaceId: UUID - if v2HasNonNullParam(params, "surface_id") { - guard let requestedSurfaceId = v2UUID(params, "surface_id") else { - result = .err( - code: "not_found", - message: String( - localized: "rpc.v2.surface.respawn.surfaceNotFoundForId", - defaultValue: "Surface not found for the given surface_id" - ), - data: nil - ) + 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 } - guard let located = AppDelegate.shared?.locateSurface(surfaceId: requestedSurfaceId), - let locatedWorkspace = located.tabManager.tabs.first(where: { $0.id == located.workspaceId }) else { - result = .err( - code: "not_found", - message: String( - localized: "rpc.v2.surface.respawn.surfaceNotFoundForId", - defaultValue: "Surface not found for the given surface_id" - ), - data: ["surface_id": requestedSurfaceId.uuidString] - ) + 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 } - ws = locatedWorkspace - tabManager = located.tabManager - surfaceId = requestedSurfaceId - } else { - guard let fallbackTabManager = fallbackTabManager else { - result = .err( - code: "unavailable", - message: String( - localized: "rpc.v2.surface.respawn.tabManagerUnavailable", - defaultValue: "Unable to access the target workspace" - ), - data: nil - ) + 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 } - guard let resolvedWorkspace = v2ResolveWorkspace(params: params, tabManager: fallbackTabManager) else { - result = .err( - code: "not_found", - message: String( - localized: "rpc.v2.surface.respawn.workspaceNotFound", - defaultValue: "Workspace not found" - ), - data: nil - ) + 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 } - guard let focusedSurfaceId = resolvedWorkspace.focusedPanelId else { - result = .err( - code: "not_found", - message: String( - localized: "rpc.v2.surface.respawn.noFocusedSurface", - defaultValue: "No focused surface" - ), - data: nil - ) + 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 } - ws = resolvedWorkspace - tabManager = fallbackTabManager - surfaceId = focusedSurfaceId - } - guard ws.terminalPanel(for: surfaceId) != nil else { - result = .err( - code: "invalid_params", - message: String( - localized: "rpc.v2.surface.respawn.surfaceNotTerminal", - defaultValue: "Surface is not a terminal" - ), - data: ["surface_id": surfaceId.uuidString] - ) - return - } - - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) + let targetIds = workspace.bonsplitController.tabs(inPane: paneId) + .map(\.id) + .filter { $0 != anchorTabId } + let closeResult = closeTabs(targetIds) + finish(["closed": closeResult.closed, "skipped_pinned": closeResult.skippedPinned]) - guard let replacementPanel = ws.respawnTerminalSurface( - panelId: surfaceId, - command: command, - workingDirectory: workingDirectory, - tmuxStartCommand: tmuxStartCommand, - focus: focus - ) else { - result = .err( - code: "internal_error", - message: String( - localized: "rpc.v2.surface.respawn.failed", - defaultValue: "Failed to respawn surface" - ), - data: ["surface_id": surfaceId.uuidString] - ) - return + default: + result = .err(code: "invalid_params", message: "Unknown tab action", data: [ + "action": action, + "supported_actions": supportedActions + ]) } - - 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), - "type": replacementPanel.panelType.rawValue, - "window_id": v2OrNull(windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: windowId) - ]) } + 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) + // MARK: - V2 Surface Methods + + @MainActor + @discardableResult + private func closeSurfaceRecordingHistory(in workspace: Workspace, surfaceId: UUID, force: Bool) -> Bool { + if let tabId = workspace.surfaceIdFromPanelId(surfaceId) { + return workspace.requestCloseTabRecordingHistory(tabId, force: force) } - let panelType = v2PanelType(params, "type") ?? .terminal - let agentOptions = v2AgentSessionOptions(params: params) - if panelType == .agentSession, let error = agentOptions.error { - return error + workspace.markCloseHistoryEligible(panelId: surfaceId) + return workspace.closePanel(surfaceId, force: force) + } + + func v2ResolveWorkspace(params: [String: Any], tabManager: TabManager) -> Workspace? { + if let wsId = v2UUID(params, "workspace_id") { + return tabManager.tabs.first(where: { $0.id == wsId }) } - let urlStr = v2String(params, "url") - let url = urlStr.flatMap { URL(string: $0) } - let workingDirectory = v2OptionalTrimmedRawString(params, "working_directory") - let initialCommand = v2OptionalTrimmedRawString(params, "initial_command") - let tmuxStartCommand = v2OptionalTrimmedRawString(params, "tmux_start_command") - let remotePTYSessionID = v2OptionalTrimmedRawString(params, "remote_pty_session_id") - let startupEnvironment = v2TrimmedStringMap(params, keys: ["startup_environment", "initial_env"]) - if panelType == .browser, BrowserAvailabilitySettings.isDisabled() { - return v2BrowserDisabledExternalOpenResult(rawURL: urlStr, url: url, tabManager: tabManager) + if let surfaceId = v2UUID(params, "surface_id") + ?? v2UUID(params, "terminal_id") + ?? v2UUID(params, "tab_id") { + return tabManager.tabs.first(where: { $0.panels[surfaceId] != nil }) + } + if let paneId = v2UUID(params, "pane_id"), + let located = v2LocatePane(paneId) { + guard located.tabManager === tabManager else { return nil } + return located.workspace } + guard let wsId = tabManager.selectedTabId else { return nil } + return tabManager.tabs.first(where: { $0.id == wsId }) + } - 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? - let focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - if panelType == .browser { - newPanelId = ws.newBrowserSurface( - inPane: paneId, - url: url, - focus: focus, - creationPolicy: .automationPreload - )?.id - } else if panelType == .agentSession { - newPanelId = ws.newAgentSessionSurface( - inPane: paneId, - providerID: agentOptions.providerID, - rendererKind: agentOptions.rendererKind, - workingDirectory: workingDirectory, - focus: focus - )?.id - } else { - newPanelId = ws.newTerminalSurface( - inPane: paneId, - focus: focus, - workingDirectory: workingDirectory, - initialCommand: initialCommand, - tmuxStartCommand: tmuxStartCommand, - startupEnvironment: startupEnvironment, - remotePTYSessionID: remotePTYSessionID - )?.id - } - guard let newPanelId else { - result = .err(code: "internal_error", message: "Failed to create surface", data: nil) - return + + + + + + + + @MainActor + + + + + private func v2AgentSessionOptions(params: [String: Any]) -> ( + providerID: AgentSessionProviderID, + rendererKind: AgentSessionRendererKind, + error: V2CallResult? + ) { + let providerRaw = v2String(params, "provider_id") ?? v2String(params, "provider") + let rendererRaw = v2String(params, "renderer_kind") ?? v2String(params, "renderer") + + let providerID: AgentSessionProviderID + if let providerRaw { + switch v2NormalizedToken(providerRaw) { + case "codex": + providerID = .codex + case "claude", "claudecode": + providerID = .claude + case "opencode": + providerID = .opencode + default: + return ( + .codex, + .react, + .err( + code: "invalid_params", + message: "Invalid provider (codex|claude|opencode)", + data: ["provider": providerRaw] + ) + ) } - - 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 - ]) + } else { + providerID = .codex } - 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) + let rendererKind: AgentSessionRendererKind + if let rendererRaw { + switch v2NormalizedToken(rendererRaw) { + case "react": + rendererKind = .react + case "solid": + rendererKind = .solid + default: + return ( + providerID, + .react, + .err( + code: "invalid_params", + message: "Invalid renderer (react|solid)", + data: ["renderer": rendererRaw] + ) + ) + } + } else { + rendererKind = .react } - 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 - } + return (providerID, rendererKind, nil) + } - 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. - let ok = closeSurfaceRecordingHistory(in: ws, surfaceId: surfaceId, force: true) - result = ok - ? .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))]) - : .err(code: "internal_error", message: "Failed to close surface", data: ["surface_id": surfaceId.uuidString]) - } - return result - } // `internal` (not `private`): the Pane domain's app conformance forwards // `pane.join` to this body. The Surface domain extraction will relocate it. @@ -7716,137 +5401,9 @@ class TerminalController { 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 focus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) - 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, focus: focus) 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(reason: "terminalController.v2SurfaceRefresh") - 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 v2DebugTerminals(params _: [String: Any]) -> V2CallResult { + func v2DebugTerminals(params _: [String: Any]) -> V2CallResult { var payload: [String: Any]? v2MainSync { @@ -8057,302 +5614,59 @@ class TerminalController { "hosted_view_class": className(hostedView) ?? "nil", "hosted_view_in_window": terminalSurface.isViewInWindow, "hosted_view_in_headless_bootstrap_window": terminalSurface.isHeadlessStartupWindow(hostedView.window), - "hosted_view_has_superview": hostedView.superview != nil, - "hosted_view_hidden": hostedView.isHidden, - "hosted_view_hidden_or_ancestor_hidden": hostedView.isHiddenOrHasHiddenAncestor, - "hosted_view_alpha": hostedView.alphaValue, - "hosted_view_visible_in_ui": hostedView.debugPortalVisibleInUI, - "hosted_view_superview_chain": superviewClassChain(for: hostedView), - "surface_view_first_responder": hostedView.isSurfaceViewFirstResponder(), - "hosted_view_frame": rectPayload(hostedView.frame), - "hosted_view_bounds": rectPayload(hostedView.bounds), - "hosted_view_frame_in_window": rectPayload(hostedView.debugPortalFrameInWindow), - "portal_binding_state": portalState.state, - "portal_binding_generation": v2OrNull(portalState.generation), - "portal_host_id": v2OrNull(portalHostLease.hostId), - "portal_host_in_window": v2OrNull(portalHostLease.inWindow), - "portal_host_area": v2OrNull(portalHostLease.area.map(Double.init)), - "tty": v2OrNull(ttyName), - "current_directory": v2OrNull(currentDirectory), - "requested_working_directory": v2OrNull(nonEmpty(terminalSurface.requestedWorkingDirectory)), - "initial_command": v2OrNull(nonEmpty(terminalSurface.debugInitialCommand())), - "tmux_start_command": v2OrNull(nonEmpty(terminalSurface.debugTmuxStartCommand())), - "git_branch": v2OrNull(nonEmpty(gitBranchState?.branch)), - "git_dirty": v2OrNull(gitBranchState?.isDirty), - "listening_ports": listeningPorts, - "key_state_indicator": v2OrNull(nonEmpty(terminalSurface.currentKeyStateIndicatorText)), - "last_known_workspace_id": lastKnownWorkspaceId.uuidString, - "last_known_workspace_ref": v2Ref(kind: .workspace, uuid: lastKnownWorkspaceId), - "teardown_requested": teardownRequest.requestedAt != nil, - "teardown_requested_at": v2OrNull(iso8601String(teardownRequest.requestedAt)), - "teardown_requested_age_seconds": v2OrNull(ageSeconds(since: teardownRequest.requestedAt)), - "teardown_requested_reason": v2OrNull(nonEmpty(teardownRequest.reason)) - ] - - if title == nil, let fallbackTitle = mapped?.terminalPanel.displayTitle, !fallbackTitle.isEmpty { - item["surface_title"] = fallbackTitle - } - return item - } - - payload = [ - "count": terminals.count, - "terminals": terminals - ] - } - - guard let payload else { - return .err(code: "unavailable", message: "AppDelegate not available", 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: UUID? - if params["surface_id"] != nil { - surfaceId = v2UUID(params, "surface_id") - guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return - } - } else { - surfaceId = 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 - switch terminalPanel.sendInputResult(text) { - case .sent: - // 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(reason: "terminalController.v2SurfaceSendText") - queued = false - case .queued: - queued = true - case .inputQueueFull: - result = .err(code: "input_queue_full", message: Self.terminalInputQueueFullMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .surfaceUnavailable: - result = .err(code: "surface_unavailable", message: Self.terminalSurfaceUnavailableMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .processExited: - result = .err(code: "process_exited", message: Self.terminalProcessExitedMessage, data: ["surface_id": surfaceId.uuidString]) - return - } -#if DEBUG - let sendMs = (ProcessInfo.processInfo.systemUptime - sendStart) * 1000.0 - cmuxDebugLog( - "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) - } + "hosted_view_has_superview": hostedView.superview != nil, + "hosted_view_hidden": hostedView.isHidden, + "hosted_view_hidden_or_ancestor_hidden": hostedView.isHiddenOrHasHiddenAncestor, + "hosted_view_alpha": hostedView.alphaValue, + "hosted_view_visible_in_ui": hostedView.debugPortalVisibleInUI, + "hosted_view_superview_chain": superviewClassChain(for: hostedView), + "surface_view_first_responder": hostedView.isSurfaceViewFirstResponder(), + "hosted_view_frame": rectPayload(hostedView.frame), + "hosted_view_bounds": rectPayload(hostedView.bounds), + "hosted_view_frame_in_window": rectPayload(hostedView.debugPortalFrameInWindow), + "portal_binding_state": portalState.state, + "portal_binding_generation": v2OrNull(portalState.generation), + "portal_host_id": v2OrNull(portalHostLease.hostId), + "portal_host_in_window": v2OrNull(portalHostLease.inWindow), + "portal_host_area": v2OrNull(portalHostLease.area.map(Double.init)), + "tty": v2OrNull(ttyName), + "current_directory": v2OrNull(currentDirectory), + "requested_working_directory": v2OrNull(nonEmpty(terminalSurface.requestedWorkingDirectory)), + "initial_command": v2OrNull(nonEmpty(terminalSurface.debugInitialCommand())), + "tmux_start_command": v2OrNull(nonEmpty(terminalSurface.debugTmuxStartCommand())), + "git_branch": v2OrNull(nonEmpty(gitBranchState?.branch)), + "git_dirty": v2OrNull(gitBranchState?.isDirty), + "listening_ports": listeningPorts, + "key_state_indicator": v2OrNull(nonEmpty(terminalSurface.currentKeyStateIndicatorText)), + "last_known_workspace_id": lastKnownWorkspaceId.uuidString, + "last_known_workspace_ref": v2Ref(kind: .workspace, uuid: lastKnownWorkspaceId), + "teardown_requested": teardownRequest.requestedAt != nil, + "teardown_requested_at": v2OrNull(iso8601String(teardownRequest.requestedAt)), + "teardown_requested_age_seconds": v2OrNull(ageSeconds(since: teardownRequest.requestedAt)), + "teardown_requested_reason": v2OrNull(nonEmpty(teardownRequest.reason)) + ] - 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: UUID? - if params["surface_id"] != nil { - surfaceId = v2UUID(params, "surface_id") - guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return + if title == nil, let fallbackTitle = mapped?.terminalPanel.displayTitle, !fallbackTitle.isEmpty { + item["surface_title"] = fallbackTitle } - } else { - surfaceId = 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 sendResult = terminalPanel.sendNamedKeyResult(key) - switch sendResult { - case .sent: - terminalPanel.surface.forceRefresh(reason: "terminalController.v2SurfaceSendKey") - case .queued: - break - case .unknownKey: - result = .err(code: "invalid_params", message: "Unknown key", data: ["key": key]) - return - case .inputQueueFull: - result = .err(code: "input_queue_full", message: Self.terminalInputQueueFullMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .surfaceUnavailable: - result = .err(code: "surface_unavailable", message: Self.terminalSurfaceUnavailableMessage, data: ["surface_id": surfaceId.uuidString]) - return - case .processExited: - result = .err(code: "process_exited", message: Self.terminalProcessExitedMessage, data: ["surface_id": surfaceId.uuidString]) - return + return item } - 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": sendResult == .queued, "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) + payload = [ + "count": terminals.count, + "terminals": terminals + ] } - 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: UUID? - if params["surface_id"] != nil { - surfaceId = v2UUID(params, "surface_id") - guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return - } - } else { - surfaceId = 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(reason: "terminalController.v2SurfaceClearHistory") - 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) - ]) + guard let payload else { + return .err(code: "unavailable", message: "AppDelegate not available", data: nil) } - - return result + return .ok(payload) } - 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 rawSnapshot: TerminalTextRawSnapshot? - var resolvedContext: (workspaceId: UUID, surfaceId: UUID, windowId: UUID?)? - var result: V2CallResult? - v2MainSync { - guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else { - result = .err(code: "not_found", message: "Workspace not found", data: nil) - return - } - let surfaceId: UUID? - if params["surface_id"] != nil { - surfaceId = v2UUID(params, "surface_id") - guard surfaceId != nil else { - result = .err(code: "not_found", message: "Surface not found for the given surface_id", data: nil) - return - } - } else { - surfaceId = 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 - } - rawSnapshot = readTerminalTextRawSnapshot( - terminalPanel: terminalPanel, - includeScrollback: includeScrollback - ) - resolvedContext = (ws.id, surfaceId, v2ResolveWindowId(tabManager: tabManager)) - } - if let result { - return result - } - guard let rawSnapshot, let resolvedContext else { - return .err(code: "internal_error", message: "Failed to read terminal text", data: nil) - } - switch Self.terminalTextPayload( - from: rawSnapshot, - includeScrollback: includeScrollback, - lineLimit: lineLimit - ) { - case .success(let payload): - return .ok([ - "text": payload.text, - "base64": payload.base64, - "workspace_id": resolvedContext.workspaceId.uuidString, - "workspace_ref": v2Ref(kind: .workspace, uuid: resolvedContext.workspaceId), - "surface_id": resolvedContext.surfaceId.uuidString, - "surface_ref": v2Ref(kind: .surface, uuid: resolvedContext.surfaceId), - "window_id": v2OrNull(resolvedContext.windowId?.uuidString), - "window_ref": v2Ref(kind: .window, uuid: resolvedContext.windowId) - ]) - case .failure(let error): - return .err(code: "internal_error", message: error.message, data: nil) - } - } struct TerminalTextRawSnapshot { var viewport: String? @@ -8370,7 +5684,7 @@ class TerminalController { let message: String } - private func readTerminalTextRawSnapshot( + func readTerminalTextRawSnapshot( terminalPanel: TerminalPanel, includeScrollback: Bool ) -> TerminalTextRawSnapshot? { @@ -8643,36 +5957,6 @@ class TerminalController { ) } - 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 - } - - 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 - } - - v2MaybeFocusWindow(for: tabManager) - v2MaybeSelectWorkspace(tabManager, workspace: ws) - - 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 - } private func v2FeedbackOpen(params: [String: Any]) -> V2CallResult { let workspaceId = v2UUID(params, "workspace_id") @@ -16500,7 +13784,7 @@ class TerminalController { return nil } - private func orderedPanels(in tab: Workspace) -> [any Panel] { + func orderedPanels(in tab: Workspace) -> [any Panel] { // Single source of truth for spatial (left-to-right, top-to-bottom) panel // order lives on `Workspace.orderedPanelIds`, derived from bonsplit's tab // ordering. This avoids relying on Dictionary iteration order and keeps the @@ -19776,6 +17060,106 @@ class TerminalController { return workspace.terminalPanel(for: surfaceID) } + // Restored: still used by the v1 close-workspace path (its v2 + // counterpart moved to ControlCommandCoordinator). + private func workspaceCloseProtectedMessage() -> String { + String( + localized: "workspace.closeProtected.message", + defaultValue: "Pinned workspaces can't be closed while pinned. Unpin the workspace first." + ) + } + + // Shared workspace-create implementation (restored): the workspace.create + // command moved to ControlCommandCoordinator, but v2MobileWorkspaceCreate + // still drives this body for the mobile data-plane create path. + private func v2WorkspaceCreate( + params: [String: Any], + tabManager resolvedTabManager: TabManager? = nil + ) -> V2CallResult { + guard let tabManager = resolvedTabManager ?? v2ResolveTabManager(params: params) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + + let requestedWorkingDirectory = v2RawString(params, "working_directory")?.trimmingCharacters(in: .whitespacesAndNewlines) + let workingDirectory = (requestedWorkingDirectory?.isEmpty == false) ? requestedWorkingDirectory : nil + + let requestedInitialCommand = v2RawString(params, "initial_command")?.trimmingCharacters(in: .whitespacesAndNewlines) + let initialCommand = (requestedInitialCommand?.isEmpty == false) ? requestedInitialCommand : nil + + let rawInitialEnv = v2StringMap(params, "initial_env") ?? [:] + let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in + let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return } + result[key] = pair.value + } + let cwd: String? + if let workingDirectory { + cwd = workingDirectory + } else 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 + } + + let requestedTitle = v2RawString(params, "title")?.trimmingCharacters(in: .whitespacesAndNewlines) + let title = (requestedTitle?.isEmpty == false) ? requestedTitle : nil + let description = v2RawString(params, "description") + + // Decode optional layout param (same JSON schema as cmux.json layout field). + // Validate before creating the workspace so malformed layouts fail fast. + var layoutNode: CmuxLayoutNode? + if let rawLayout = params["layout"] { + guard JSONSerialization.isValidJSONObject(rawLayout), + let layoutData = try? JSONSerialization.data(withJSONObject: rawLayout) else { + return .err(code: "invalid_params", message: "layout must be a valid JSON object", data: nil) + } + do { + layoutNode = try JSONDecoder().decode(CmuxLayoutNode.self, from: layoutData) + } catch { + return .err(code: "invalid_params", message: "Invalid layout: \(error.localizedDescription)", data: nil) + } + } + + var newId: UUID? + var initialSurfaceId: UUID? + let shouldFocus = v2FocusAllowed(requested: v2Bool(params, "focus") ?? false) + let shouldEagerLoadTerminal = v2Bool(params, "eager_load_terminal") ?? !shouldFocus + let shouldAutoRefreshMetadata = v2Bool(params, "auto_refresh_metadata") ?? true + v2MainSync { + let ws = tabManager.addWorkspace( + title: title, + workingDirectory: cwd, + initialTerminalCommand: layoutNode == nil ? initialCommand : nil, + initialTerminalEnvironment: layoutNode == nil ? initialEnv : [:], + select: shouldFocus, + eagerLoadTerminal: shouldEagerLoadTerminal, + autoRefreshMetadata: shouldAutoRefreshMetadata + ) + ws.setCustomDescription(description) + if let layoutNode { + ws.applyCustomLayout(layoutNode, baseCwd: cwd ?? ws.currentDirectory) + } + newId = ws.id + initialSurfaceId = ws.focusedPanelId + } + + 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), + "surface_id": v2OrNull(initialSurfaceId?.uuidString), + "surface_ref": v2Ref(kind: .surface, uuid: initialSurfaceId) + ]) + } + private func v2MobileWorkspaceCreate(params: [String: Any]) -> V2CallResult { guard let tabManager = v2ResolveTabManager(params: params) else { return .err(code: "unavailable", message: "Workspace context is unavailable", data: nil) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 7769034901c3..d0a7c031c472 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -612,6 +612,11 @@ C0DE00000000000000000C52 /* TerminalController+ControlMobileHostContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */; }; C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */; }; C0DE00000000000000000C56 /* TerminalController+ControlPaneContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */; }; + C0DE00000000000000000C64 /* TerminalController+ControlSurfaceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C63 /* TerminalController+ControlSurfaceContext.swift */; }; + C0DE00000000000000000C66 /* TerminalController+ControlSurfaceContext2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C65 /* TerminalController+ControlSurfaceContext2.swift */; }; + C0DE00000000000000000C68 /* TerminalController+ControlSurfaceContext3.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C67 /* TerminalController+ControlSurfaceContext3.swift */; }; + C0DE00000000000000000C6A /* TerminalController+ControlSurfaceContext4.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C69 /* TerminalController+ControlSurfaceContext4.swift */; }; + C0DE00000000000000000C62 /* TerminalController+ControlWorkspaceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C61 /* TerminalController+ControlWorkspaceContext.swift */; }; C0DE00000000000000000C54 /* TerminalController+ControlWorkspaceGroupContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */; }; D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */; }; A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; }; @@ -1354,6 +1359,11 @@ C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlMobileHostContext.swift"; sourceTree = ""; }; C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlNotificationContext.swift"; sourceTree = ""; }; C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlPaneContext.swift"; sourceTree = ""; }; + C0DE00000000000000000C63 /* TerminalController+ControlSurfaceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceContext.swift"; sourceTree = ""; }; + C0DE00000000000000000C65 /* TerminalController+ControlSurfaceContext2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceContext2.swift"; sourceTree = ""; }; + C0DE00000000000000000C67 /* TerminalController+ControlSurfaceContext3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceContext3.swift"; sourceTree = ""; }; + C0DE00000000000000000C69 /* TerminalController+ControlSurfaceContext4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceContext4.swift"; sourceTree = ""; }; + C0DE00000000000000000C61 /* TerminalController+ControlWorkspaceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlWorkspaceContext.swift"; sourceTree = ""; }; C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlWorkspaceGroupContext.swift"; sourceTree = ""; }; D7AB0000000000000000000C /* TerminalController+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; A5001019 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; @@ -1865,6 +1875,11 @@ C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */, C0DE00000000000000000C43 /* TerminalController+ControlFeedContext.swift */, C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */, + C0DE00000000000000000C61 /* TerminalController+ControlWorkspaceContext.swift */, + C0DE00000000000000000C63 /* TerminalController+ControlSurfaceContext.swift */, + C0DE00000000000000000C65 /* TerminalController+ControlSurfaceContext2.swift */, + C0DE00000000000000000C67 /* TerminalController+ControlSurfaceContext3.swift */, + C0DE00000000000000000C69 /* TerminalController+ControlSurfaceContext4.swift */, C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */, C0DE00000000000000000C53 /* TerminalController+ControlWorkspaceGroupContext.swift */, C0DE00000000000000000C55 /* TerminalController+ControlPaneContext.swift */, @@ -3040,6 +3055,11 @@ C0DE00000000000000000C52 /* TerminalController+ControlMobileHostContext.swift in Sources */, C0DE00000000000000000C46 /* TerminalController+ControlNotificationContext.swift in Sources */, C0DE00000000000000000C56 /* TerminalController+ControlPaneContext.swift in Sources */, + C0DE00000000000000000C64 /* TerminalController+ControlSurfaceContext.swift in Sources */, + C0DE00000000000000000C66 /* TerminalController+ControlSurfaceContext2.swift in Sources */, + C0DE00000000000000000C68 /* TerminalController+ControlSurfaceContext3.swift in Sources */, + C0DE00000000000000000C6A /* TerminalController+ControlSurfaceContext4.swift in Sources */, + C0DE00000000000000000C62 /* TerminalController+ControlWorkspaceContext.swift in Sources */, C0DE00000000000000000C54 /* TerminalController+ControlWorkspaceGroupContext.swift in Sources */, D7AB0000000000000000000B /* TerminalController+MoveTabToNewWorkspace.swift in Sources */, A5001007 /* TerminalController.swift in Sources */, From 8f7ac8989294e8d6b41c38e7a4abcbc64e70ad4a Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 16:01:03 -0700 Subject: [PATCH 07/21] stage 3c: organize coordinator files into per-domain subfolders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coordinator/ had grown to 105 files. Move each domain's coordinator extension, seam protocol, and value/resolution/snapshot types into a per-domain subfolder (Window/AppFocus/Feed/Notification/Pane/Surface/Workspace/WorkspaceGroup/ MobileHost). The 4 shared core files stay at the Coordinator/ root: ControlCommandContext (umbrella), ControlCommandCoordinator (core dispatch + handle registry), ControlCommandCoordinator+Params (shared param/ref helpers), ControlRoutingSelectors. SwiftPM globs sources recursively, so this is purely organizational — no Package.swift/import changes. Budget paths updated for the moved Pane/Workspace coordinator files; TC.swift budget corrected to 17680 (the two restored shared bodies grew it after the last bump). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 6 +++--- .../Coordinator/{ => AppFocus}/ControlAppFocusContext.swift | 0 .../{ => AppFocus}/ControlCommandCoordinator+AppFocus.swift | 0 .../{ => Feed}/ControlCommandCoordinator+Feed.swift | 0 .../Coordinator/{ => Feed}/ControlFeedContext.swift | 0 .../ControlCommandCoordinator+MobileHost.swift | 0 .../{ => MobileHost}/ControlMobileHostContext.swift | 0 .../ControlCommandCoordinator+Notification.swift | 0 .../{ => Notification}/ControlNotificationContext.swift | 0 .../ControlNotificationCreateResolution.swift | 0 .../ControlNotificationDismissResolution.swift | 0 .../ControlNotificationMarkReadResolution.swift | 0 .../ControlNotificationOpenResolution.swift | 0 .../{ => Notification}/ControlNotificationSnapshot.swift | 0 .../{ => Notification}/ControlNotificationStrings.swift | 0 .../ControlNotificationTargetedDeliveryResolution.swift | 0 .../{ => Pane}/ControlCommandCoordinator+Pane.swift | 0 .../Coordinator/{ => Pane}/ControlPaneBreakResolution.swift | 0 .../Coordinator/{ => Pane}/ControlPaneContext.swift | 0 .../Coordinator/{ => Pane}/ControlPaneCreateInputs.swift | 0 .../{ => Pane}/ControlPaneCreateResolution.swift | 0 .../Coordinator/{ => Pane}/ControlPaneFocusResolution.swift | 0 .../Coordinator/{ => Pane}/ControlPaneGridSize.swift | 0 .../Coordinator/{ => Pane}/ControlPaneJoinResolution.swift | 0 .../Coordinator/{ => Pane}/ControlPaneLastResolution.swift | 0 .../Coordinator/{ => Pane}/ControlPaneListSnapshot.swift | 0 .../Coordinator/{ => Pane}/ControlPanePixelFrame.swift | 0 .../Coordinator/{ => Pane}/ControlPaneResizeInputs.swift | 0 .../{ => Pane}/ControlPaneResizeResolution.swift | 0 .../Coordinator/{ => Pane}/ControlPaneSummary.swift | 0 .../Coordinator/{ => Pane}/ControlPaneSurfaceSummary.swift | 0 .../{ => Pane}/ControlPaneSurfacesSnapshot.swift | 0 .../Coordinator/{ => Pane}/ControlPaneSwapResolution.swift | 0 .../{ => Surface}/ControlCommandCoordinator+Surface.swift | 0 .../{ => Surface}/ControlCommandCoordinator+Surface2.swift | 0 .../{ => Surface}/ControlCommandCoordinator+Surface3.swift | 0 .../ControlSurfaceBrowserDisabledOutcome.swift | 0 .../ControlSurfaceClearHistoryResolution.swift | 0 .../{ => Surface}/ControlSurfaceCloseResolution.swift | 0 .../Coordinator/{ => Surface}/ControlSurfaceContext.swift | 0 .../{ => Surface}/ControlSurfaceCreateInputs.swift | 0 .../{ => Surface}/ControlSurfaceCreateResolution.swift | 0 .../{ => Surface}/ControlSurfaceCurrentSnapshot.swift | 0 .../{ => Surface}/ControlSurfaceFocusResolution.swift | 0 .../{ => Surface}/ControlSurfaceHealthEntry.swift | 0 .../{ => Surface}/ControlSurfaceHealthSnapshot.swift | 0 .../{ => Surface}/ControlSurfaceInputStrings.swift | 0 .../{ => Surface}/ControlSurfaceListSnapshot.swift | 0 .../{ => Surface}/ControlSurfacePortsKickResolution.swift | 0 .../{ => Surface}/ControlSurfaceReadTextResolution.swift | 0 .../{ => Surface}/ControlSurfaceRefreshResolution.swift | 0 .../{ => Surface}/ControlSurfaceReorderInputs.swift | 0 .../{ => Surface}/ControlSurfaceReorderResolution.swift | 0 .../ControlSurfaceReportShellStateResolution.swift | 0 .../{ => Surface}/ControlSurfaceReportTTYResolution.swift | 0 .../{ => Surface}/ControlSurfaceRespawnInputs.swift | 0 .../{ => Surface}/ControlSurfaceRespawnResolution.swift | 0 .../{ => Surface}/ControlSurfaceRespawnStrings.swift | 0 .../{ => Surface}/ControlSurfaceResumeBinding.swift | 0 .../{ => Surface}/ControlSurfaceResumeResolution.swift | 0 .../{ => Surface}/ControlSurfaceResumeSetInputs.swift | 0 .../{ => Surface}/ControlSurfaceResumeSnapshot.swift | 0 .../{ => Surface}/ControlSurfaceSendResolution.swift | 0 .../{ => Surface}/ControlSurfaceSplitInputs.swift | 0 .../{ => Surface}/ControlSurfaceSplitResolution.swift | 0 .../Coordinator/{ => Surface}/ControlSurfaceSummary.swift | 0 .../ControlSurfaceTriggerFlashResolution.swift | 0 .../{ => Window}/ControlCommandCoordinator+Window.swift | 0 .../{ => Window}/ControlCurrentWindowResolution.swift | 0 .../Coordinator/{ => Window}/ControlDisplayInfo.swift | 0 .../{ => Window}/ControlMoveAllWindowsResult.swift | 0 .../Coordinator/{ => Window}/ControlWindowContext.swift | 0 .../Coordinator/{ => Window}/ControlWindowSummary.swift | 0 .../ControlCommandCoordinator+Workspace.swift | 0 .../{ => Workspace}/ControlWorkspaceCloseResolution.swift | 0 .../{ => Workspace}/ControlWorkspaceContext.swift | 0 .../{ => Workspace}/ControlWorkspaceCreateInputs.swift | 0 .../{ => Workspace}/ControlWorkspaceCreateResolution.swift | 0 .../{ => Workspace}/ControlWorkspaceCurrentResolution.swift | 0 .../ControlWorkspaceEqualizeResolution.swift | 0 .../{ => Workspace}/ControlWorkspaceListResolution.swift | 0 .../ControlWorkspaceMoveToWindowResolution.swift | 0 .../ControlWorkspaceNavigationResolution.swift | 0 .../ControlWorkspacePromptSubmitResolution.swift | 0 .../ControlWorkspaceRemotePTYAttachEndResolution.swift | 0 .../{ => Workspace}/ControlWorkspaceRemoteResolution.swift | 0 ...ControlWorkspaceRemoteTerminalSessionEndResolution.swift | 0 .../ControlWorkspaceReorderManyResolution.swift | 0 .../{ => Workspace}/ControlWorkspaceReorderPlanItem.swift | 0 .../{ => Workspace}/ControlWorkspaceReorderResolution.swift | 0 .../{ => Workspace}/ControlWorkspaceRoutedResolution.swift | 0 .../{ => Workspace}/ControlWorkspaceStrings.swift | 0 .../{ => Workspace}/ControlWorkspaceSummary.swift | 0 .../ControlCommandCoordinator+WorkspaceGroup.swift | 0 .../ControlWorkspaceGroupAddResolution.swift | 0 .../{ => WorkspaceGroup}/ControlWorkspaceGroupContext.swift | 0 .../ControlWorkspaceGroupCreateResolution.swift | 0 .../ControlWorkspaceGroupFocusResolution.swift | 0 .../ControlWorkspaceGroupListResolution.swift | 0 .../ControlWorkspaceGroupNewWorkspaceResolution.swift | 0 .../ControlWorkspaceGroupSnapshot.swift | 0 .../{ => WorkspaceGroup}/ControlWorkspaceGroupStrings.swift | 0 102 files changed, 3 insertions(+), 3 deletions(-) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => AppFocus}/ControlAppFocusContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => AppFocus}/ControlCommandCoordinator+AppFocus.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Feed}/ControlCommandCoordinator+Feed.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Feed}/ControlFeedContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => MobileHost}/ControlCommandCoordinator+MobileHost.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => MobileHost}/ControlMobileHostContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlCommandCoordinator+Notification.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationCreateResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationDismissResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationMarkReadResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationOpenResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationStrings.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Notification}/ControlNotificationTargetedDeliveryResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlCommandCoordinator+Pane.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneBreakResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneCreateInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneCreateResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneFocusResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneGridSize.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneJoinResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneLastResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneListSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPanePixelFrame.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneResizeInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneResizeResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneSummary.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneSurfaceSummary.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneSurfacesSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Pane}/ControlPaneSwapResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlCommandCoordinator+Surface.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlCommandCoordinator+Surface2.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlCommandCoordinator+Surface3.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceBrowserDisabledOutcome.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceClearHistoryResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceCloseResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceCreateInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceCreateResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceCurrentSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceFocusResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceHealthEntry.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceHealthSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceInputStrings.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceListSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfacePortsKickResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceReadTextResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceRefreshResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceReorderInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceReorderResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceReportShellStateResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceReportTTYResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceRespawnInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceRespawnResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceRespawnStrings.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceResumeBinding.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceResumeResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceResumeSetInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceResumeSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceSendResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceSplitInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceSplitResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceSummary.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Surface}/ControlSurfaceTriggerFlashResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Window}/ControlCommandCoordinator+Window.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Window}/ControlCurrentWindowResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Window}/ControlDisplayInfo.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Window}/ControlMoveAllWindowsResult.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Window}/ControlWindowContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Window}/ControlWindowSummary.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlCommandCoordinator+Workspace.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceCloseResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceCreateInputs.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceCreateResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceCurrentResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceEqualizeResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceListResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceMoveToWindowResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceNavigationResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspacePromptSubmitResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceRemotePTYAttachEndResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceRemoteResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceRemoteTerminalSessionEndResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceReorderManyResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceReorderPlanItem.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceReorderResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceRoutedResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceStrings.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => Workspace}/ControlWorkspaceSummary.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlCommandCoordinator+WorkspaceGroup.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupAddResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupContext.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupCreateResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupFocusResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupListResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupNewWorkspaceResolution.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupSnapshot.swift (100%) rename Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/{ => WorkspaceGroup}/ControlWorkspaceGroupStrings.swift (100%) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d5fb7774e322..a42af086dc6d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,7 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 32655 CLI/cmux.swift -17580 Sources/TerminalController.swift + 17680 Sources/TerminalController.swift 19820 Sources/Workspace.swift 19209 Sources/ContentView.swift 18011 Sources/AppDelegate.swift @@ -187,6 +187,6 @@ 502 Sources/CmuxEventPublishing.swift 502 Sources/Settings/ConfigSource.swift 611 Sources/TerminalController+ControlPaneContext.swift -533 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift -871 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift +533 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift +871 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 805 Sources/TerminalController+ControlWorkspaceContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlAppFocusContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/AppFocus/ControlAppFocusContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlAppFocusContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/AppFocus/ControlAppFocusContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+AppFocus.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/AppFocus/ControlCommandCoordinator+AppFocus.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+AppFocus.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/AppFocus/ControlCommandCoordinator+AppFocus.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Feed.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Feed.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlCommandCoordinator+Feed.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlFeedContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlFeedContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Feed/ControlFeedContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+MobileHost.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+MobileHost.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlCommandCoordinator+MobileHost.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMobileHostContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMobileHostContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/MobileHost/ControlMobileHostContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Notification.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Notification.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlCommandCoordinator+Notification.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationCreateResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationCreateResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationCreateResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationDismissResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationDismissResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationDismissResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationDismissResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationMarkReadResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationMarkReadResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationMarkReadResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationMarkReadResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationOpenResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationOpenResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationOpenResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationOpenResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationStrings.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationStrings.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationStrings.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationTargetedDeliveryResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationTargetedDeliveryResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlNotificationTargetedDeliveryResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Notification/ControlNotificationTargetedDeliveryResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Pane.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneBreakResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneBreakResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneBreakResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneBreakResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneCreateInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneCreateInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneCreateResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneCreateResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneCreateResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneFocusResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneFocusResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneFocusResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneFocusResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneGridSize.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneGridSize.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneGridSize.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneGridSize.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneJoinResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneJoinResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneJoinResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneJoinResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneLastResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneLastResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneLastResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneLastResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneListSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneListSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneListSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneListSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPanePixelFrame.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPanePixelFrame.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPanePixelFrame.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPanePixelFrame.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneResizeInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneResizeInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneResizeResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneResizeResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneResizeResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSummary.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSummary.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSummary.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfaceSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSurfaceSummary.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfaceSummary.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSurfaceSummary.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfacesSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSurfacesSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSurfacesSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSurfacesSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSwapResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSwapResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlPaneSwapResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlPaneSwapResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface2.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface2.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface2.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface2.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface3.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Surface3.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceBrowserDisabledOutcome.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceBrowserDisabledOutcome.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceBrowserDisabledOutcome.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceBrowserDisabledOutcome.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceClearHistoryResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceClearHistoryResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceClearHistoryResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceClearHistoryResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCloseResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCloseResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCloseResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCloseResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCreateInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCreateInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCreateResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCreateResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCreateResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCurrentSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCurrentSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceCurrentSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceCurrentSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceFocusResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceFocusResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceFocusResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceFocusResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthEntry.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthEntry.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthEntry.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceHealthSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceHealthSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceInputStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceInputStrings.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceInputStrings.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceInputStrings.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceListSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceListSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceListSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceListSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfacePortsKickResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfacePortsKickResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfacePortsKickResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfacePortsKickResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReadTextResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReadTextResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReadTextResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReadTextResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRefreshResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRefreshResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRefreshResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRefreshResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReorderInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReorderInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReorderResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReorderResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReorderResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportShellStateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReportShellStateResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportShellStateResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReportShellStateResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportTTYResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReportTTYResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceReportTTYResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceReportTTYResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRespawnInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRespawnInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRespawnResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRespawnResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRespawnStrings.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceRespawnStrings.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceRespawnStrings.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeBinding.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeBinding.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeBinding.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeBinding.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSetInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSetInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSetInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSetInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceResumeSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceResumeSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSendResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSendResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSendResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSendResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSplitResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSummary.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceSummary.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSummary.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceTriggerFlashResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceTriggerFlashResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlSurfaceTriggerFlashResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceTriggerFlashResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlCommandCoordinator+Window.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Window.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlCommandCoordinator+Window.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCurrentWindowResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlCurrentWindowResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCurrentWindowResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlCurrentWindowResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlDisplayInfo.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlDisplayInfo.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlDisplayInfo.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlDisplayInfo.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMoveAllWindowsResult.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlMoveAllWindowsResult.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlMoveAllWindowsResult.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlMoveAllWindowsResult.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlWindowContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlWindowContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlWindowSummary.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWindowSummary.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Window/ControlWindowSummary.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+Workspace.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCloseResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCloseResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCloseResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCloseResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateInputs.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateInputs.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateInputs.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCreateResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCurrentResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCurrentResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceCurrentResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCurrentResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceEqualizeResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceEqualizeResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceEqualizeResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceEqualizeResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceListResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceListResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceListResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceListResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceMoveToWindowResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceMoveToWindowResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceMoveToWindowResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceMoveToWindowResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceNavigationResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceNavigationResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceNavigationResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceNavigationResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspacePromptSubmitResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspacePromptSubmitResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspacePromptSubmitResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspacePromptSubmitResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemotePTYAttachEndResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRemotePTYAttachEndResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemotePTYAttachEndResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRemotePTYAttachEndResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRemoteResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRemoteResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteTerminalSessionEndResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRemoteTerminalSessionEndResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRemoteTerminalSessionEndResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRemoteTerminalSessionEndResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderManyResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceReorderManyResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderManyResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceReorderManyResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderPlanItem.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceReorderPlanItem.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderPlanItem.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceReorderPlanItem.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceReorderResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceReorderResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceReorderResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRoutedResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRoutedResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceRoutedResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceRoutedResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceStrings.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceStrings.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceSummary.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceSummary.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceSummary.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+WorkspaceGroup.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator+WorkspaceGroup.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlCommandCoordinator+WorkspaceGroup.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupAddResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupAddResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupAddResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupAddResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupContext.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupContext.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupCreateResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupCreateResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupCreateResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupFocusResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupFocusResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupFocusResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupFocusResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupListResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupListResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupListResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupListResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupNewWorkspaceResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupNewWorkspaceResolution.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupNewWorkspaceResolution.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupNewWorkspaceResolution.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupSnapshot.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupSnapshot.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupSnapshot.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupStrings.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupStrings.swift similarity index 100% rename from Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlWorkspaceGroupStrings.swift rename to Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/WorkspaceGroup/ControlWorkspaceGroupStrings.swift From 487baaf074ad88383750c30876d7fd2b6e859963 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 16:06:19 -0700 Subject: [PATCH 08/21] stage 3c: dedupe workspace.create onto the shared v2WorkspaceCreate body The Workspace lift had reimplemented workspace.create logic in the conformance while the original v2WorkspaceCreate(params:tabManager:) was restored for the mobile data-plane caller -- two copies that could diverge. Replace the typed reimplementation with a passthrough that forwards to the single shared v2WorkspaceCreate (relaxed private->internal) and bridges its Foundation result, exactly like surface.move/debug.terminals/mobile. Deletes the now-unused ControlWorkspaceCreateInputs/ControlWorkspaceCreateResolution. One source of truth, byte-identical wire output. Comprehensive socket sweep on ctl3c1 (all 10 domains, 38 ok + 13 expected validation errors, zero crashes) confirms no regression: workspace.create happy path + its cwd/layout validation errors preserved; pane.resize amount=1e30 now clamps (invalid_state) instead of trapping (the int/double NSNumber fix). 133 package tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/swift-file-length-budget.tsv | 4 +- .../ControlCommandCoordinator+Workspace.swift | 55 +++------------ .../Workspace/ControlWorkspaceContext.swift | 17 ++--- .../ControlWorkspaceCreateInputs.swift | 67 ------------------- .../ControlWorkspaceCreateResolution.swift | 23 ------- .../ControlCommandContextTestStubs.swift | 7 +- ...alController+ControlWorkspaceContext.swift | 67 +++---------------- Sources/TerminalController.swift | 2 +- 8 files changed, 31 insertions(+), 211 deletions(-) delete mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateInputs.swift delete mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateResolution.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index a42af086dc6d..d0bf5bb8bcab 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -188,5 +188,5 @@ 502 Sources/Settings/ConfigSource.swift 611 Sources/TerminalController+ControlPaneContext.swift 533 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift -871 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift -805 Sources/TerminalController+ControlWorkspaceContext.swift +834 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +756 Sources/TerminalController+ControlWorkspaceContext.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift index d8eced88927a..4c28f5ca7b0c 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift @@ -140,53 +140,16 @@ extension ControlCommandCoordinator { // MARK: - Create /// `workspace.create` — create a workspace. + /// + /// A passthrough to the still-shared `v2WorkspaceCreate(params:tabManager:)` + /// (which the mobile data-plane `v2MobileWorkspaceCreate` also drives), rather + /// than a typed lift: the create body parses all ~10 params and mints refs + /// itself, so a single source of truth is both deduplicated and exactly + /// faithful. The conformance bridges the body's Foundation payload to a + /// `ControlCallResult`, like `surface.move` / `debug.terminals`. func workspaceCreate(_ params: [String: JSONValue]) -> ControlCallResult { - let workingDirectory = optionalTrimmedRawString(params, "working_directory") - let initialCommand = optionalTrimmedRawString(params, "initial_command") - let title = optionalTrimmedRawString(params, "title") - let description = rawString(params, "description") - - let rawInitialEnv = stringMap(params, "initial_env") ?? [:] - let initialEnv = rawInitialEnv.reduce(into: [String: String]()) { result, pair in - let key = pair.key.trimmingCharacters(in: .whitespacesAndNewlines) - guard !key.isEmpty else { return } - result[key] = pair.value - } - - let inputs = ControlWorkspaceCreateInputs( - title: title, - description: description, - workingDirectory: workingDirectory, - rawCWD: params["cwd"], - initialCommand: initialCommand, - initialEnv: initialEnv, - rawLayout: params["layout"], - focusRequested: bool(params, "focus") ?? false, - eagerLoadTerminal: bool(params, "eager_load_terminal"), - autoRefreshMetadata: bool(params, "auto_refresh_metadata") - ) - - let resolution = context?.controlCreateWorkspace( - routing: routingSelectors(params), - inputs: inputs - ) ?? .tabManagerUnavailable - switch resolution { - case .tabManagerUnavailable: - return .err(code: "unavailable", message: "TabManager not available", data: nil) - case .invalidParams(let message): - return .err(code: "invalid_params", message: message, data: nil) - case .creationFailed: - return .err(code: "internal_error", message: "Failed to create workspace", data: nil) - case .resolved(let windowID, let workspaceID, let surfaceID): - return .ok(.object([ - "window_id": orNull(windowID?.uuidString), - "window_ref": ref(.window, windowID), - "workspace_id": .string(workspaceID.uuidString), - "workspace_ref": ref(.workspace, workspaceID), - "surface_id": orNull(surfaceID?.uuidString), - "surface_ref": ref(.surface, surfaceID), - ])) - } + context?.controlWorkspaceCreate(params: params) + ?? .err(code: "unavailable", message: "TabManager not available", data: nil) } // MARK: - Select / close / move diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift index ac1089a2c838..023435a8f178 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceContext.swift @@ -46,17 +46,14 @@ public protocol ControlWorkspaceContext: AnyObject { /// - Returns: The current resolution. func controlWorkspaceCurrent(routing: ControlRoutingSelectors) -> ControlWorkspaceCurrentResolution - /// Creates a workspace for `workspace.create`. + /// Creates a workspace for `workspace.create`, forwarding to the shared + /// `v2WorkspaceCreate` body (also driven by the mobile data-plane create + /// path) and bridging its Foundation payload — a single source of truth. /// - /// - Parameters: - /// - routing: The routing selectors used for TabManager resolution. - /// - inputs: The pre-parsed create inputs (the app does the remaining - /// app-typed `cwd`/`layout` validation and the create). - /// - Returns: The create resolution. - func controlCreateWorkspace( - routing: ControlRoutingSelectors, - inputs: ControlWorkspaceCreateInputs - ) -> ControlWorkspaceCreateResolution + /// - Parameter params: The raw command params; the body parses them and mints + /// refs itself. + /// - Returns: The bridged call result. + func controlWorkspaceCreate(params: [String: JSONValue]) -> ControlCallResult /// Selects a workspace for `workspace.select` (focuses its window when it /// belongs to another window). diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateInputs.swift deleted file mode 100644 index f9305249be86..000000000000 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateInputs.swift +++ /dev/null @@ -1,67 +0,0 @@ -public import Foundation - -/// The pre-parsed inputs for `workspace.create`, carried across -/// ``ControlWorkspaceContext`` so the seam runs the app-typed remainder (the -/// `cwd`-type / `layout` decode validation, the `addWorkspace` call) on live -/// state. -/// -/// The coordinator parses every scalar/string/map param exactly as the legacy -/// body did, but defers the `cwd` string-type check and the `layout` JSON -/// decode to the conformance because both require app types -/// (`JSONSerialization` shape / `CmuxLayoutNode`). The raw `cwd` and `layout` -/// values are passed through as ``JSONValue`` so the app can reproduce the -/// identical `invalid_params` failures and the `NSNull`/missing distinction. -public struct ControlWorkspaceCreateInputs: Sendable, Equatable { - /// The resolved title, or `nil` (legacy: trimmed, empty → nil). - public let title: String? - /// The resolved description (untrimmed raw string), or `nil`. - public let description: String? - /// The resolved `working_directory` override, or `nil` (trimmed, empty → - /// nil). When present it wins over `cwd`. - public let workingDirectory: String? - /// The raw `cwd` param value, if the key was present (may be non-string, - /// which the app rejects). Absent key → `nil`. - public let rawCWD: JSONValue? - /// The resolved `initial_command`, or `nil`. - public let initialCommand: String? - /// The resolved `initial_env` map (trimmed keys, empties dropped). - public let initialEnv: [String: String] - /// The raw `layout` param value, if present (decoded app-side). Absent → - /// `nil`. - public let rawLayout: JSONValue? - /// The requested `focus` flag, defaulted to `false` (the app runs it through - /// its `v2FocusAllowed` gate, which also drives the `eagerLoadTerminal` - /// default). - public let focusRequested: Bool - /// The parsed `eager_load_terminal` override, or `nil` when absent (legacy - /// default `!shouldFocus`, computed app-side). - public let eagerLoadTerminal: Bool? - /// The parsed `auto_refresh_metadata` override, or `nil` when absent (legacy - /// default `true`). - public let autoRefreshMetadata: Bool? - - /// Creates the create inputs. - public init( - title: String?, - description: String?, - workingDirectory: String?, - rawCWD: JSONValue?, - initialCommand: String?, - initialEnv: [String: String], - rawLayout: JSONValue?, - focusRequested: Bool, - eagerLoadTerminal: Bool?, - autoRefreshMetadata: Bool? - ) { - self.title = title - self.description = description - self.workingDirectory = workingDirectory - self.rawCWD = rawCWD - self.initialCommand = initialCommand - self.initialEnv = initialEnv - self.rawLayout = rawLayout - self.focusRequested = focusRequested - self.eagerLoadTerminal = eagerLoadTerminal - self.autoRefreshMetadata = autoRefreshMetadata - } -} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateResolution.swift deleted file mode 100644 index 0b10e5bd7188..000000000000 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCreateResolution.swift +++ /dev/null @@ -1,23 +0,0 @@ -public import Foundation - -/// The outcome of `workspace.create`, preserving the legacy body's failure modes -/// and the resolved window/workspace/surface the success echoes back. -public enum ControlWorkspaceCreateResolution: Sendable, Equatable { - /// No TabManager resolved (legacy `unavailable` / "TabManager not - /// available"). - case tabManagerUnavailable - /// A param was malformed (legacy `invalid_params`). Carries the exact - /// message the app produced (`"cwd must be a string"`, `"layout must be a - /// valid JSON object"`, or `"Invalid layout: …"`). - case invalidParams(message: String) - /// Workspace creation failed (legacy `internal_error` / "Failed to create - /// workspace"). - case creationFailed - /// The workspace was created. Carries the owning window id (may be absent), - /// the new workspace id, and the initial surface id (may be absent). - case resolved( - windowID: UUID?, - workspaceID: UUID, - initialSurfaceID: UUID? - ) -} diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index fa5aeb7fa190..abd4c7409571 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -187,10 +187,9 @@ extension ControlWorkspaceContext { .tabManagerUnavailable } - func controlCreateWorkspace( - routing: ControlRoutingSelectors, - inputs: ControlWorkspaceCreateInputs - ) -> ControlWorkspaceCreateResolution { .tabManagerUnavailable } + func controlWorkspaceCreate(params: [String: JSONValue]) -> ControlCallResult { + .err(code: "unavailable", message: "", data: nil) + } func controlSelectWorkspace( routing: ControlRoutingSelectors, diff --git a/Sources/TerminalController+ControlWorkspaceContext.swift b/Sources/TerminalController+ControlWorkspaceContext.swift index 12aca13e62ca..9ae7b0431f64 100644 --- a/Sources/TerminalController+ControlWorkspaceContext.swift +++ b/Sources/TerminalController+ControlWorkspaceContext.swift @@ -108,65 +108,16 @@ extension TerminalController: ControlWorkspaceContext { // MARK: - Create - func controlCreateWorkspace( - routing: ControlRoutingSelectors, - inputs: ControlWorkspaceCreateInputs - ) -> ControlWorkspaceCreateResolution { - guard let tabManager = resolveTabManager(routing: routing) else { - return .tabManagerUnavailable + /// `workspace.create` forwards to the single shared `v2WorkspaceCreate` body + /// (also driven by `v2MobileWorkspaceCreate`), bridging its Foundation result + /// — one source of truth for the create logic, byte-identical wire output. + func controlWorkspaceCreate(params: [String: JSONValue]) -> ControlCallResult { + switch v2WorkspaceCreate(params: params.mapValues(\.foundationObject)) { + case let .ok(payload): + return .ok(JSONValue(foundationObject: payload) ?? .object([:])) + case let .err(code, message, data): + return .err(code: code, message: message, data: data.flatMap { JSONValue(foundationObject: $0) }) } - - let cwd: String? - if let workingDirectory = inputs.workingDirectory { - cwd = workingDirectory - } else if let rawCWD = inputs.rawCWD, !rawCWD.isControlNull { - guard case .string(let str) = rawCWD else { - return .invalidParams(message: "cwd must be a string") - } - cwd = str - } else { - cwd = nil - } - - // Decode optional layout param (same JSON schema as cmux.json layout - // field). Validate before creating the workspace so malformed layouts - // fail fast. - var layoutNode: CmuxLayoutNode? - if let rawLayout = inputs.rawLayout, !rawLayout.isControlNull { - let foundationLayout = rawLayout.foundationObject - guard JSONSerialization.isValidJSONObject(foundationLayout), - let layoutData = try? JSONSerialization.data(withJSONObject: foundationLayout) else { - return .invalidParams(message: "layout must be a valid JSON object") - } - do { - layoutNode = try JSONDecoder().decode(CmuxLayoutNode.self, from: layoutData) - } catch { - return .invalidParams(message: "Invalid layout: \(error.localizedDescription)") - } - } - - let shouldFocus = v2FocusAllowed(requested: inputs.focusRequested) - let shouldEagerLoadTerminal = inputs.eagerLoadTerminal ?? !shouldFocus - let shouldAutoRefreshMetadata = inputs.autoRefreshMetadata ?? true - - let ws = tabManager.addWorkspace( - title: inputs.title, - workingDirectory: cwd, - initialTerminalCommand: layoutNode == nil ? inputs.initialCommand : nil, - initialTerminalEnvironment: layoutNode == nil ? inputs.initialEnv : [:], - select: shouldFocus, - eagerLoadTerminal: shouldEagerLoadTerminal, - autoRefreshMetadata: shouldAutoRefreshMetadata - ) - ws.setCustomDescription(inputs.description) - if let layoutNode { - ws.applyCustomLayout(layoutNode, baseCwd: cwd ?? ws.currentDirectory) - } - let newId = ws.id - let initialSurfaceId = ws.focusedPanelId - - let windowId = AppDelegate.shared?.windowId(for: tabManager) - return .resolved(windowID: windowId, workspaceID: newId, initialSurfaceID: initialSurfaceId) } // MARK: - Select / close / move diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 200873e42f54..08f1152d62df 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -17072,7 +17072,7 @@ class TerminalController { // Shared workspace-create implementation (restored): the workspace.create // command moved to ControlCommandCoordinator, but v2MobileWorkspaceCreate // still drives this body for the mobile data-plane create path. - private func v2WorkspaceCreate( + func v2WorkspaceCreate( params: [String: Any], tabManager resolvedTabManager: TabManager? = nil ) -> V2CallResult { From 6f2cba58580f0f703fa0859ab63c8018356ef963 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 17:00:26 -0700 Subject: [PATCH 09/21] stage 3c: fix 8 divergences found by Workspace+Surface adversarial review Surface (4): surface.clear_history with a present-but-invalid surface_id silently cleared the FOCUSED surface instead of returning not_found (wrong-target side effect; hasSurfaceIDParam now crosses the seam like send_text); surface.split with an unrecognized direction returned unavailable instead of invalid_params 'Missing or invalid direction (left|right|up|down)' (coordinator now validates the parseSplitDirection token set + a drift-safe .invalidDirection case); surface.split error precedence restored (direction -> agent-session -> divider; the agent-session token check moved before divider parsing); surface.resume.* explicit target restored to surface_id ?? tab_id ONLY (terminal_id is a general routing alias but was never a resume target) and the window branch now requires a RESOLVABLE window_id like origin. Workspace (4): select/close/rename get the routing precheck so unresolvable routing returns unavailable before param validation (legacy TabManager-first order, matching reorder); workspace.current with a stale selectedTabId returns .ok with workspace:null again instead of not_found. Dead code removed (JSONValue.isControlNull, surfaceIDForInput). All confirmed by live socket sweep on the rebuilt ctl3c1 (each previously-wrong response now byte-matches origin). 133 package tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ControlCommandCoordinator+Surface.swift | 23 ++++++++++- .../ControlCommandCoordinator+Surface2.swift | 10 +---- .../ControlCommandCoordinator+Surface3.swift | 22 +++++++++- .../Surface/ControlSurfaceContext.swift | 19 +++++++-- .../ControlSurfaceSplitResolution.swift | 4 ++ .../ControlCommandCoordinator+Workspace.swift | 26 ++++++++++-- .../ControlWorkspaceCurrentResolution.swift | 10 +++-- .../ControlCommandContextTestStubs.swift | 11 ++++- ...nalController+ControlSurfaceContext2.swift | 7 ++-- ...nalController+ControlSurfaceContext3.swift | 8 +++- ...nalController+ControlSurfaceContext4.swift | 40 ++++++++++++++++--- ...alController+ControlWorkspaceContext.swift | 16 +++----- 12 files changed, 150 insertions(+), 46 deletions(-) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift index d7c0f4a851f6..a83285633c25 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift @@ -217,13 +217,25 @@ extension ControlCommandCoordinator { guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) ?? false else { return .err(code: "unavailable", message: "TabManager not available", data: nil) } - guard let directionRaw = string(params, "direction") else { + // Token set mirrors the app's `parseSplitDirection`; validating it here + // preserves the legacy error ORDER (direction → agent-session → divider). + guard let directionRaw = string(params, "direction"), + ["left", "l", "right", "r", "up", "u", "down", "d"].contains(directionRaw.lowercased()) else { return .err( code: "invalid_params", message: "Missing or invalid direction (left|right|up|down)", data: nil ) } + // Legacy rejected agent-session BEFORE divider validation (token match + // mirrors the app's `v2PanelType` normalized-token mapping). + if let typeRaw = string(params, "type"), normalizedToken(typeRaw) == "agentsession" { + return .err( + code: "invalid_params", + message: "agent-session is only supported by surface.create", + data: .object(["type": .string("agentSession")]) + ) + } let parsedDivider = initialDividerPosition(params) if let error = parsedDivider.error { return error } @@ -246,6 +258,15 @@ extension ControlCommandCoordinator { switch resolution { case .tabManagerUnavailable: return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .invalidDirection: + // Drift-safety net: the coordinator pre-validates the same token set, + // so this only fires if the app's parseSplitDirection ever diverges — + // and then it still emits the legacy error. + return .err( + code: "invalid_params", + message: "Missing or invalid direction (left|right|up|down)", + data: nil + ) case .agentSessionRejected(let typeRawValue): return .err( code: "invalid_params", diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface2.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface2.swift index 7dc31ff8fe87..734340e8ae4c 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface2.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface2.swift @@ -107,7 +107,8 @@ extension ControlCommandCoordinator { } let resolution = context?.controlSurfaceClearHistory( routing: routing, - surfaceID: surfaceIDForInput(params) + surfaceID: uuid(params, "surface_id"), + hasSurfaceIDParam: params["surface_id"] != nil ) ?? .tabManagerUnavailable switch resolution { case .tabManagerUnavailable: @@ -342,11 +343,4 @@ extension ControlCommandCoordinator { // MARK: - helpers - /// The `surface_id` selector for the body methods that resolve the focused - /// surface when no `surface_id` is given (`clear_history`). Matches the legacy - /// `v2UUID(params, "surface_id") ?? focused` precedence by returning the parsed - /// id (or `nil` to defer to the focused surface). - func surfaceIDForInput(_ params: [String: JSONValue]) -> UUID? { - uuid(params, "surface_id") - } } diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift index 61e2549cfd37..a711ff923eb5 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface3.swift @@ -57,10 +57,22 @@ extension ControlCommandCoordinator { autoResume: source == "agent-hook" ? (bool(params, "auto_resume") ?? false) : false ) return surfaceResumeResult( - context?.controlSurfaceResumeSet(routing: routing, inputs: inputs) ?? .setFailed + context?.controlSurfaceResumeSet( + routing: routing, + explicitTargetID: surfaceResumeExplicitTargetID(params), + hasResolvedWindowID: uuid(params, "window_id") != nil, + inputs: inputs + ) ?? .setFailed ) } + /// The legacy resume-target selector: `surface_id ?? tab_id` ONLY — the + /// `terminal_id` alias that general routing honors was never part of the + /// resume-target precedence (origin `v2ResolveSurfaceResumeTarget`). + private func surfaceResumeExplicitTargetID(_ params: [String: JSONValue]) -> UUID? { + uuid(params, "surface_id") ?? uuid(params, "tab_id") + } + // MARK: - resume.get /// `surface.resume.get` — read a surface's resume binding. @@ -71,7 +83,11 @@ extension ControlCommandCoordinator { return .err(code: "unavailable", message: Self.surfaceWindowUnavailableMessage, data: nil) } return surfaceResumeResult( - context?.controlSurfaceResumeGet(routing: routing) ?? .surfaceNotFound + context?.controlSurfaceResumeGet( + routing: routing, + explicitTargetID: surfaceResumeExplicitTargetID(params), + hasResolvedWindowID: uuid(params, "window_id") != nil + ) ?? .surfaceNotFound ) } @@ -86,6 +102,8 @@ extension ControlCommandCoordinator { } let resolution = context?.controlSurfaceResumeClear( routing: routing, + explicitTargetID: surfaceResumeExplicitTargetID(params), + hasResolvedWindowID: uuid(params, "window_id") != nil, expectedCheckpointID: optionalTrimmedRawString(params, "checkpoint_id") ?? optionalTrimmedRawString(params, "checkpointId"), expectedSource: optionalTrimmedRawString(params, "source") diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift index d1f3e0803677..1d60cbd0ec7b 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift @@ -155,11 +155,16 @@ public protocol ControlSurfaceContext: AnyObject { /// /// - Parameters: /// - routing: The routing selectors. - /// - surfaceID: The explicit `surface_id`, or `nil` for the focused surface. + /// - surfaceID: The parsed `surface_id`, or `nil` (focused-surface fallback + /// only when the param was absent). + /// - hasSurfaceIDParam: Whether a `surface_id` param was present at all — + /// present-but-unparseable must error, not silently fall back to the + /// focused surface (legacy `params["surface_id"] != nil` guard). /// - Returns: The clear-history resolution. func controlSurfaceClearHistory( routing: ControlRoutingSelectors, - surfaceID: UUID? + surfaceID: UUID?, + hasSurfaceIDParam: Bool ) -> ControlSurfaceClearHistoryResolution /// Triggers the focus flash for `surface.trigger_flash`. @@ -245,6 +250,8 @@ public protocol ControlSurfaceContext: AnyObject { /// - Returns: The resume resolution. func controlSurfaceResumeSet( routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool, inputs: ControlSurfaceResumeSetInputs ) -> ControlSurfaceResumeResolution @@ -253,7 +260,11 @@ public protocol ControlSurfaceContext: AnyObject { /// - Parameter routing: The routing selectors (with the surface-resume /// precedence). /// - Returns: The resume resolution. - func controlSurfaceResumeGet(routing: ControlRoutingSelectors) -> ControlSurfaceResumeResolution + func controlSurfaceResumeGet( + routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool + ) -> ControlSurfaceResumeResolution /// Clears the resume binding for `surface.resume.clear`, honoring the optional /// expected checkpoint/source guards. @@ -265,6 +276,8 @@ public protocol ControlSurfaceContext: AnyObject { /// - Returns: The resume resolution. func controlSurfaceResumeClear( routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool, expectedCheckpointID: String?, expectedSource: String? ) -> ControlSurfaceResumeResolution diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitResolution.swift index dfff32cdc6a9..bb7a6ec99047 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitResolution.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceSplitResolution.swift @@ -10,6 +10,10 @@ public import Foundation public enum ControlSurfaceSplitResolution: Sendable, Equatable { /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). case tabManagerUnavailable + /// The direction token did not parse (legacy `invalid_params` / + /// "Missing or invalid direction (left|right|up|down)"). The coordinator + /// pre-validates the same token set, so this is a drift-safety net. + case invalidDirection /// The type token resolved to `agent-session` (legacy `invalid_params` / /// "agent-session is only supported by surface.create", `data: {"type": …}`). case agentSessionRejected(typeRawValue: String) diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift index 4c28f5ca7b0c..91188075b12c 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift @@ -132,7 +132,7 @@ extension ControlCommandCoordinator { "window_ref": ref(.window, windowID), "workspace_id": .string(workspaceID.uuidString), "workspace_ref": ref(.workspace, workspaceID), - "workspace": workspaceSummaryPayload(summary, index: index, selected: true), + "workspace": summary.map { workspaceSummaryPayload($0, index: index, selected: true) } ?? .null, ])) } } @@ -156,11 +156,17 @@ extension ControlCommandCoordinator { /// `workspace.select` — select a workspace by id. func workspaceSelect(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + // Legacy resolved the TabManager BEFORE param validation, so unresolvable + // routing wins over a missing/invalid param (`unavailable` first). + guard context?.controlWorkspaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } guard let workspaceID = uuid(params, "workspace_id") else { return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) } let resolution = context?.controlSelectWorkspace( - routing: routingSelectors(params), + routing: routing, workspaceID: workspaceID ) ?? .tabManagerUnavailable switch resolution { @@ -183,11 +189,17 @@ extension ControlCommandCoordinator { /// `workspace.close` — close a workspace by id. func workspaceClose(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + // Legacy resolved the TabManager BEFORE param validation, so unresolvable + // routing wins over a missing/invalid param (`unavailable` first). + guard context?.controlWorkspaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } guard let workspaceID = uuid(params, "workspace_id") else { return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) } let resolution = context?.controlCloseWorkspace( - routing: routingSelectors(params), + routing: routing, workspaceID: workspaceID ) ?? .tabManagerUnavailable switch resolution { @@ -526,6 +538,12 @@ extension ControlCommandCoordinator { /// `workspace.rename` — set a workspace's custom title. func workspaceRename(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + // Legacy resolved the TabManager BEFORE param validation, so unresolvable + // routing wins over a missing/invalid param (`unavailable` first). + guard context?.controlWorkspaceRoutingResolvesTabManager(routing: routing) ?? false else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } guard let workspaceID = uuid(params, "workspace_id") else { return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil) } @@ -533,7 +551,7 @@ extension ControlCommandCoordinator { return .err(code: "invalid_params", message: "Missing or invalid title", data: nil) } let resolution = context?.controlRenameWorkspace( - routing: routingSelectors(params), + routing: routing, workspaceID: workspaceID, title: title ) ?? .tabManagerUnavailable diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCurrentResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCurrentResolution.swift index f290bfa127a3..04806d6671bd 100644 --- a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCurrentResolution.swift +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlWorkspaceCurrentResolution.swift @@ -9,13 +9,15 @@ public enum ControlWorkspaceCurrentResolution: Sendable, Equatable { /// A TabManager resolved but had no selected workspace (legacy `not_found` / /// "No workspace selected"). case noWorkspaceSelected - /// The selected workspace was snapshotted. Carries the owning window id (may - /// be absent), the selected workspace's id, its index within the list (if - /// resolvable), and its summary. + /// The selected workspace id resolved. Carries the owning window id (may be + /// absent), the selected workspace's id, its index within the list (if + /// resolvable), and its summary — `nil` when `selectedTabId` points at a + /// workspace missing from `tabs` (the legacy body still answered `.ok` with + /// `"workspace": null` in that state). case resolved( windowID: UUID?, workspaceID: UUID, index: Int?, - summary: ControlWorkspaceSummary + summary: ControlWorkspaceSummary? ) } diff --git a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index abd4c7409571..53f4dc27a8dd 100644 --- a/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -352,7 +352,8 @@ extension ControlSurfaceContext { func controlSurfaceClearHistory( routing: ControlRoutingSelectors, - surfaceID: UUID? + surfaceID: UUID?, + hasSurfaceIDParam: Bool ) -> ControlSurfaceClearHistoryResolution { .tabManagerUnavailable } func controlSurfaceTriggerFlash( @@ -388,15 +389,21 @@ extension ControlSurfaceContext { func controlSurfaceResumeSet( routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool, inputs: ControlSurfaceResumeSetInputs ) -> ControlSurfaceResumeResolution { .surfaceNotFound } func controlSurfaceResumeGet( - routing: ControlRoutingSelectors + routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool ) -> ControlSurfaceResumeResolution { .surfaceNotFound } func controlSurfaceResumeClear( routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool, expectedCheckpointID: String?, expectedSource: String? ) -> ControlSurfaceResumeResolution { .surfaceNotFound } diff --git a/Sources/TerminalController+ControlSurfaceContext2.swift b/Sources/TerminalController+ControlSurfaceContext2.swift index 087da9a7ce3b..33bf51ae7cf3 100644 --- a/Sources/TerminalController+ControlSurfaceContext2.swift +++ b/Sources/TerminalController+ControlSurfaceContext2.swift @@ -70,11 +70,10 @@ extension TerminalController { guard let tabManager = resolveTabManager(routing: routing) else { return .tabManagerUnavailable } - // Direction validated by the coordinator; the app maps it to SplitDirection. + // The coordinator pre-validates the same token set; if parseSplitDirection + // ever drifts this still surfaces as the legacy invalid_params error. guard let direction = parseSplitDirection(inputs.directionRaw) else { - // Unreachable: the coordinator pre-validates direction is non-empty, but - // an unrecognized token still maps to the same legacy invalid_params. - return .tabManagerUnavailable + return .invalidDirection } let panelType = inputs.typeRaw.flatMap { surfacePanelType(forRawToken: $0) } ?? .terminal if panelType == .agentSession { diff --git a/Sources/TerminalController+ControlSurfaceContext3.swift b/Sources/TerminalController+ControlSurfaceContext3.swift index fa8b7e4dd89a..2561be684321 100644 --- a/Sources/TerminalController+ControlSurfaceContext3.swift +++ b/Sources/TerminalController+ControlSurfaceContext3.swift @@ -99,7 +99,8 @@ extension TerminalController { func controlSurfaceClearHistory( routing: ControlRoutingSelectors, - surfaceID: UUID? + surfaceID: UUID?, + hasSurfaceIDParam: Bool ) -> ControlSurfaceClearHistoryResolution { guard let tabManager = resolveTabManager(routing: routing) else { return .tabManagerUnavailable @@ -107,6 +108,11 @@ extension TerminalController { guard let ws = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { return .workspaceNotFound } + // Legacy: a present-but-unparseable surface_id errors; it must never fall + // back to clearing the focused surface (wrong-target side effect). + if hasSurfaceIDParam, surfaceID == nil { + return .surfaceNotFoundForID + } guard let surfaceId = surfaceID ?? ws.focusedPanelId else { return .noFocusedSurface } diff --git a/Sources/TerminalController+ControlSurfaceContext4.swift b/Sources/TerminalController+ControlSurfaceContext4.swift index 96899a5ffd32..6f33177bb669 100644 --- a/Sources/TerminalController+ControlSurfaceContext4.swift +++ b/Sources/TerminalController+ControlSurfaceContext4.swift @@ -18,9 +18,14 @@ extension TerminalController { /// selectors the coordinator already parsed in place of the raw params. private func resolveSurfaceResumeTarget( routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool, fallbackTabManager: TabManager ) -> (tabManager: TabManager, workspace: Workspace, surfaceId: UUID)? { - if let explicitSurfaceId = routing.surfaceID { + // Legacy explicit target: surface_id ?? tab_id ONLY (terminal_id is a + // general-routing alias but was never a resume target), and the window + // branch requires a RESOLVABLE window_id (legacy `v2UUID != nil`). + if let explicitSurfaceId = explicitTargetID { if let explicitWorkspaceId = routing.workspaceID { guard let workspace = fallbackTabManager.tabs.first(where: { $0.id == explicitWorkspaceId }), workspace.terminalPanel(for: explicitSurfaceId) != nil else { @@ -28,7 +33,7 @@ extension TerminalController { } return (fallbackTabManager, workspace, explicitSurfaceId) } - if routing.hasWindowIDParam { + if hasResolvedWindowID { guard let workspace = fallbackTabManager.tabs.first(where: { $0.terminalPanel(for: explicitSurfaceId) != nil }) else { @@ -151,6 +156,8 @@ extension TerminalController { func controlSurfaceResumeSet( routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool, inputs: ControlSurfaceResumeSetInputs ) -> ControlSurfaceResumeResolution { guard let tabManager = resolveTabManager(routing: routing) else { @@ -167,7 +174,12 @@ extension TerminalController { autoResume: inputs.autoResume, updatedAt: Date().timeIntervalSince1970 ) - guard let target = resolveSurfaceResumeTarget(routing: routing, fallbackTabManager: tabManager) else { + guard let target = resolveSurfaceResumeTarget( + routing: routing, + explicitTargetID: explicitTargetID, + hasResolvedWindowID: hasResolvedWindowID, + fallbackTabManager: tabManager + ) else { return .surfaceNotFound } let effectiveBinding = surfaceResumeBindingWithApproval(binding) @@ -183,11 +195,20 @@ extension TerminalController { )) } - func controlSurfaceResumeGet(routing: ControlRoutingSelectors) -> ControlSurfaceResumeResolution { + func controlSurfaceResumeGet( + routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool + ) -> ControlSurfaceResumeResolution { guard let tabManager = resolveTabManager(routing: routing) else { return .windowUnavailable } - guard let target = resolveSurfaceResumeTarget(routing: routing, fallbackTabManager: tabManager) else { + guard let target = resolveSurfaceResumeTarget( + routing: routing, + explicitTargetID: explicitTargetID, + hasResolvedWindowID: hasResolvedWindowID, + fallbackTabManager: tabManager + ) else { return .surfaceNotFound } return .result(surfaceResumeSnapshot( @@ -201,13 +222,20 @@ extension TerminalController { func controlSurfaceResumeClear( routing: ControlRoutingSelectors, + explicitTargetID: UUID?, + hasResolvedWindowID: Bool, expectedCheckpointID: String?, expectedSource: String? ) -> ControlSurfaceResumeResolution { guard let tabManager = resolveTabManager(routing: routing) else { return .windowUnavailable } - guard let target = resolveSurfaceResumeTarget(routing: routing, fallbackTabManager: tabManager) else { + guard let target = resolveSurfaceResumeTarget( + routing: routing, + explicitTargetID: explicitTargetID, + hasResolvedWindowID: hasResolvedWindowID, + fallbackTabManager: tabManager + ) else { return .surfaceNotFound } let currentBinding = target.workspace.surfaceResumeBinding(panelId: target.surfaceId) diff --git a/Sources/TerminalController+ControlWorkspaceContext.swift b/Sources/TerminalController+ControlWorkspaceContext.swift index 9ae7b0431f64..d0f6ce494e77 100644 --- a/Sources/TerminalController+ControlWorkspaceContext.swift +++ b/Sources/TerminalController+ControlWorkspaceContext.swift @@ -92,17 +92,19 @@ extension TerminalController: ControlWorkspaceContext { guard let tabManager = resolveTabManager(routing: routing) else { return .tabManagerUnavailable } - guard let workspaceId = tabManager.selectedTabId, - let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { + guard let workspaceId = tabManager.selectedTabId else { return .noWorkspaceSelected } + // Legacy: a selectedTabId pointing at a workspace missing from `tabs` + // still answered .ok with "workspace": null. + let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) let index = tabManager.tabs.firstIndex(where: { $0.id == workspaceId }) let windowId = AppDelegate.shared?.windowId(for: tabManager) return .resolved( windowID: windowId, workspaceID: workspaceId, index: index, - summary: controlWorkspaceSummary(workspace) + summary: workspace.map { controlWorkspaceSummary($0) } ) } @@ -746,11 +748,3 @@ extension TerminalController: ControlWorkspaceContext { } } -/// Local `JSONValue` null test for the create-input passthrough (the package's -/// own `isNull` is file-private to the coordinator extensions). -private extension JSONValue { - var isControlNull: Bool { - if case .null = self { return true } - return false - } -} From fe419f0f8aac24554609af2fd863edb32b84911b Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Wed, 10 Jun 2026 17:00:54 -0700 Subject: [PATCH 10/21] Budget: entries for the two coordinator files grown by the divergence fixes --- .github/swift-file-length-budget.tsv | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d0bf5bb8bcab..cfa961fc45f3 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,6 +2,7 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 32655 CLI/cmux.swift +518 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift 17680 Sources/TerminalController.swift 19820 Sources/Workspace.swift 19209 Sources/ContentView.swift @@ -188,5 +189,5 @@ 502 Sources/Settings/ConfigSource.swift 611 Sources/TerminalController+ControlPaneContext.swift 533 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift -834 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift +852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 756 Sources/TerminalController+ControlWorkspaceContext.swift From f731c273cd25e546013d7fd8141cb32319c4225a Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 11 Jun 2026 10:00:26 -0700 Subject: [PATCH 11/21] Fix duplicate parameter name warning (sessionID sessionID) in workspace conformance The tests-build-and-lag job failed solely on the Swift WARNING budget: the Workspace conformance's controlWorkspaceRemotePTYAttachEnd declared 'sessionID sessionID: String' (extraneous duplicate). Behavior identical; the job's build and lag phases were green. Co-Authored-By: Claude Opus 4.8 (1M context) --- Sources/TerminalController+ControlWorkspaceContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController+ControlWorkspaceContext.swift b/Sources/TerminalController+ControlWorkspaceContext.swift index d0f6ce494e77..3ba82145b968 100644 --- a/Sources/TerminalController+ControlWorkspaceContext.swift +++ b/Sources/TerminalController+ControlWorkspaceContext.swift @@ -685,7 +685,7 @@ extension TerminalController: ControlWorkspaceContext { func controlWorkspaceRemotePTYAttachEnd( workspaceID workspaceId: UUID, surfaceID surfaceId: UUID, - sessionID sessionID: String + sessionID: String ) -> ControlWorkspaceRemotePTYAttachEndResolution { let located = AppDelegate.shared?.workspaceContainingPanel( panelId: surfaceId, From 7d234dc5783bfa06c04d408cea206f9818cfcb53 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Thu, 11 Jun 2026 10:13:35 -0700 Subject: [PATCH 12/21] stage 3c: package side of System/Project/Debug/Sidebar/Browser domains (drafts integrated) Five domains drafted by the orchestrator's agents (handed off), repaired (browserNavContext accessor, allocateElementRef state call, v1 handlers unhooked from the v2 chain), wired into the umbrella + dispatch, with test stubs completed. 140 package tests green. App-side surgery follows. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ControlBrowserAutomationContext.swift | 134 ++++ .../ControlBrowserAutomationState.swift | 204 ++++++ .../Browser/ControlBrowserContext.swift | 410 +++++++++++ .../Browser/ControlBrowserCookie.swift | 49 ++ ...ControlBrowserCookiesClearResolution.swift | 10 + .../ControlBrowserCookiesGetResolution.swift | 10 + .../ControlBrowserCookiesSetResolution.swift | 18 + ...ControlBrowserDiffViewerRegistration.swift | 18 + .../ControlBrowserElementRefEntry.swift | 22 + .../ControlBrowserFocusModeAction.swift | 11 + ...ControlBrowserFocusWebViewResolution.swift | 17 + .../ControlBrowserFocusedActionTarget.swift | 23 + .../ControlBrowserHandledResolution.swift | 12 + ...ontrolBrowserImportProfileResolution.swift | 14 + .../Browser/ControlBrowserImportScope.swift | 13 + .../Browser/ControlBrowserNavAction.swift | 11 + .../Browser/ControlBrowserNavResolution.swift | 12 + .../ControlBrowserOpenSplitInputs.swift | 50 ++ .../ControlBrowserOpenSplitResolution.swift | 88 +++ .../Browser/ControlBrowserPanelContext.swift | 56 ++ .../Browser/ControlBrowserPanelFailure.swift | 23 + ...olBrowserPanelFocusWebViewResolution.swift | 17 + .../Browser/ControlBrowserPanelIdentity.swift | 21 + .../ControlBrowserPanelResolution.swift | 23 + ...ControlBrowserPanelWebViewFocusState.swift | 10 + .../Browser/ControlBrowserPendingDialog.swift | 37 + .../ControlBrowserReactGrabResolution.swift | 11 + .../ControlBrowserScreenshotResult.swift | 15 + .../Browser/ControlBrowserScriptMode.swift | 14 + .../Browser/ControlBrowserScriptOutcome.swift | 8 + .../ControlBrowserScriptResolution.swift | 21 + .../Browser/ControlBrowserScriptValue.swift | 19 + .../ControlBrowserStateApplyResolution.swift | 8 + .../Browser/ControlBrowserStateCapture.swift | 37 + ...ControlBrowserStateCaptureResolution.swift | 9 + .../Browser/ControlBrowserSurfaceTarget.swift | 27 + .../ControlBrowserTabCloseResolution.swift | 21 + .../ControlBrowserTabListSnapshot.swift | 23 + .../ControlBrowserTabNewResolution.swift | 14 + .../Browser/ControlBrowserTabSummary.swift | 32 + .../ControlBrowserTabSwitchResolution.swift | 11 + .../Browser/ControlBrowserURLResolution.swift | 10 + .../Browser/ControlBrowserZoomDirection.swift | 9 + .../ControlCommandCoordinator+Browser.swift | 486 +++++++++++++ .../ControlCommandCoordinator+Browser2.swift | 296 ++++++++ .../ControlCommandCoordinator+Browser3.swift | 642 +++++++++++++++++ .../ControlCommandCoordinator+Browser4.swift | 417 +++++++++++ ...CommandCoordinator+BrowserAutomation.swift | 595 ++++++++++++++++ ...Coordinator+BrowserAutomationActions.swift | 645 ++++++++++++++++++ ...ator+BrowserAutomationDialogsScripts.swift | 160 +++++ ...andCoordinator+BrowserAutomationFind.swift | 563 +++++++++++++++ ...Coordinator+BrowserAutomationQueries.swift | 196 ++++++ ...rolCommandCoordinator+BrowserPanelV1.swift | 193 ++++++ .../Coordinator/ControlCommandContext.swift | 9 +- .../ControlCommandCoordinator.swift | 7 + .../ControlCommandCoordinator+Debug.swift | 474 +++++++++++++ .../ControlCommandCoordinator+Debug2.swift | 428 ++++++++++++ .../ControlDebugCommandPaletteEvent.swift | 17 + .../ControlDebugCommandPaletteResult.swift | 32 + .../ControlDebugCommandPaletteSnapshot.swift | 29 + .../Debug/ControlDebugContext.swift | 299 ++++++++ .../ControlDebugFileDropPayloadKind.swift | 11 + .../ControlDebugFileDropResolution.swift | 23 + .../Debug/ControlDebugFileDropRoute.swift | 11 + ...lDebugRenameInputSelectionResolution.swift | 16 + ...trolDebugRightSidebarFocusResolution.swift | 16 + .../ControlDebugRightSidebarFocusState.swift | 49 ++ .../ControlDebugTextBoxFixtureSnapshot.swift | 66 ++ .../ControlDebugTextBoxInteraction.swift | 23 + .../Debug/ControlDebugTypeResolution.swift | 13 + .../ControlCommandCoordinator+Project.swift | 242 +++++++ ...ntrolCommandCoordinator+ProjectFiles.swift | 231 +++++++ .../Project/ControlFileOpenResolution.swift | 24 + .../Project/ControlFileOpenSurface.swift | 43 ++ .../ControlMarkdownOpenResolution.swift | 62 ++ .../Project/ControlProjectContext.swift | 166 +++++ .../ControlProjectOpenResolution.swift | 20 + .../ControlProjectSetTabResolution.swift | 9 + .../ControlProjectStateResolution.swift | 7 + .../Project/ControlProjectStateSnapshot.swift | 117 ++++ .../ControlProjectTargetResolution.swift | 10 + .../ControlProjectUpdateResolution.swift | 9 + ...CommandCoordinator+SidebarMetadataV1.swift | 435 ++++++++++++ ...trolCommandCoordinator+SidebarPaneV1.swift | 297 ++++++++ ...lCommandCoordinator+SidebarReportsV1.swift | 430 ++++++++++++ .../ControlCommandCoordinator+SidebarV1.swift | 393 +++++++++++ ...ntrolSidebarClearMetaBlockResolution.swift | 11 + ...ControlSidebarCloseSurfaceResolution.swift | 15 + .../Sidebar/ControlSidebarContext.swift | 280 ++++++++ .../ControlSidebarDragToSplitResolution.swift | 14 + .../ControlSidebarFocusedPanelInfo.swift | 21 + .../Sidebar/ControlSidebarGitBranchInfo.swift | 19 + .../ControlSidebarLogEntrySnapshot.swift | 24 + .../ControlSidebarMetadataBlockSnapshot.swift | 24 + .../ControlSidebarMetadataFormat.swift | 11 + .../ControlSidebarNewSurfaceResolution.swift | 13 + .../ControlSidebarPaneListSnapshot.swift | 24 + ...ControlSidebarPaneSurfacesResolution.swift | 36 + .../ControlSidebarPanelMutationTarget.swift | 27 + .../Sidebar/ControlSidebarPanelScope.swift | 21 + .../ControlSidebarPanelWriteResolution.swift | 22 + .../Sidebar/ControlSidebarProgressInfo.swift | 19 + .../ControlSidebarPullRequestInfo.swift | 27 + ...ControlSidebarRightSidebarResolution.swift | 14 + .../ControlSidebarSplitOffOutcome.swift | 12 + .../Sidebar/ControlSidebarStateSnapshot.swift | 73 ++ .../ControlSidebarStatusEntrySnapshot.swift | 48 ++ .../ControlSidebarSurfaceHealthRow.swift | 33 + .../Sidebar/ControlSidebarTabTarget.swift | 15 + .../ControlCommandCoordinator+System.swift | 167 +++++ ...ControlCommandCoordinator+SystemMisc.swift | 156 +++++ ...trolCommandCoordinator+SystemRouting.swift | 112 +++ ...olCommandCoordinator+SystemTabAction.swift | 120 ++++ .../ControlExtensionSidebarSnapshot.swift | 33 + .../ControlExtensionSidebarWorkspace.swift | 147 ++++ .../ControlSessionRestoreResolution.swift | 13 + .../ControlSettingsOpenResolution.swift | 10 + .../System/ControlSystemContext.swift | 123 ++++ .../System/ControlSystemTreePaneNode.swift | 43 ++ .../System/ControlSystemTreeResolution.swift | 31 + .../System/ControlSystemTreeSurfaceNode.swift | 82 +++ .../System/ControlSystemTreeWindowNode.swift | 33 + .../ControlSystemTreeWorkspaceNode.swift | 48 ++ .../System/ControlTabActionResolution.swift | 96 +++ .../ControlBrowserAutomationStateTests.swift | 125 ++++ ...ndContextTestStubs+BrowserAutomation.swift | 49 ++ ...ControlCommandContextTestStubs+Debug.swift | 66 ++ ...ntrolCommandContextTestStubs+Project.swift | 67 ++ ...mmandContextTestStubs+SidebarBrowser.swift | 360 ++++++++++ ...ontrolCommandContextTestStubs+System.swift | 51 ++ ...ControlCommandCoordinatorWindowTests.swift | 5 +- ...ller+ControlBrowserAutomationContext.swift | 265 +++++++ ...rminalController+ControlDebugContext.swift | 401 +++++++++++ ...inalController+ControlProjectContext.swift | 359 ++++++++++ ...inalController+ControlSidebarContext.swift | 310 +++++++++ ...nalController+ControlSidebarContext2.swift | 421 ++++++++++++ ...nalController+ControlSidebarContext3.swift | 391 +++++++++++ ...troller+ControlSidebarContextSupport.swift | 187 +++++ ...minalController+ControlSystemContext.swift | 352 ++++++++++ ...inalController+ControlSystemContext2.swift | 304 +++++++++ 140 files changed, 15540 insertions(+), 3 deletions(-) create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationState.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookie.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesClearResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesGetResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesSetResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserDiffViewerRegistration.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserElementRefEntry.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusModeAction.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusWebViewResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusedActionTarget.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserHandledResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportProfileResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportScope.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavAction.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitInputs.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFailure.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFocusWebViewResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelIdentity.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelWebViewFocusState.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPendingDialog.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserReactGrabResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScreenshotResult.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptMode.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptOutcome.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptValue.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateApplyResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCapture.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCaptureResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserSurfaceTarget.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabCloseResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabListSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabNewResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSummary.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSwitchResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserURLResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserZoomDirection.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser2.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser3.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser4.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomation.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationActions.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationDialogsScripts.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationFind.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationQueries.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserPanelV1.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug2.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugCommandPaletteEvent.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugCommandPaletteResult.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugCommandPaletteSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugFileDropPayloadKind.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugFileDropResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugFileDropRoute.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugRenameInputSelectionResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugRightSidebarFocusResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugRightSidebarFocusState.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugTextBoxFixtureSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugTextBoxInteraction.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugTypeResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlCommandCoordinator+Project.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlCommandCoordinator+ProjectFiles.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlFileOpenResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlFileOpenSurface.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlMarkdownOpenResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlProjectContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlProjectOpenResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlProjectSetTabResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlProjectStateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlProjectStateSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlProjectTargetResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Project/ControlProjectUpdateResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarMetadataV1.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarPaneV1.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarReportsV1.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlCommandCoordinator+SidebarV1.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarClearMetaBlockResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarCloseSurfaceResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarDragToSplitResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarFocusedPanelInfo.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarGitBranchInfo.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarLogEntrySnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarMetadataBlockSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarMetadataFormat.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarNewSurfaceResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarPaneListSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarPaneSurfacesResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarPanelMutationTarget.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarPanelScope.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarPanelWriteResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarProgressInfo.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarPullRequestInfo.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarRightSidebarResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarSplitOffOutcome.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarStateSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarStatusEntrySnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarSurfaceHealthRow.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Sidebar/ControlSidebarTabTarget.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+System.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemMisc.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemRouting.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlCommandCoordinator+SystemTabAction.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlExtensionSidebarSnapshot.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlExtensionSidebarWorkspace.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSessionRestoreResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSettingsOpenResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemContext.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreePaneNode.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeResolution.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeSurfaceNode.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeWindowNode.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlSystemTreeWorkspaceNode.swift create mode 100644 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/System/ControlTabActionResolution.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlBrowserAutomationStateTests.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+BrowserAutomation.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Project.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+SidebarBrowser.swift create mode 100644 Packages/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+System.swift create mode 100644 Sources/TerminalController+ControlBrowserAutomationContext.swift create mode 100644 Sources/TerminalController+ControlDebugContext.swift create mode 100644 Sources/TerminalController+ControlProjectContext.swift create mode 100644 Sources/TerminalController+ControlSidebarContext.swift create mode 100644 Sources/TerminalController+ControlSidebarContext2.swift create mode 100644 Sources/TerminalController+ControlSidebarContext3.swift create mode 100644 Sources/TerminalController+ControlSidebarContextSupport.swift create mode 100644 Sources/TerminalController+ControlSystemContext.swift create mode 100644 Sources/TerminalController+ControlSystemContext2.swift diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationContext.swift new file mode 100644 index 000000000000..18feded73649 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationContext.swift @@ -0,0 +1,134 @@ +public import Foundation + +/// The browser DOM-automation slice of the control-command seam (a +/// constituent of the ``ControlCommandContext`` umbrella): element actions, +/// JS eval plumbing, frames, and dialogs. +/// +/// The conformer (`TerminalController`) keeps everything that is irreducibly +/// app-coupled — the WKWebView JS execution/run-loop pump +/// (`v2RunBrowserJavaScript` / `v2RunJavaScript`, shared with the still-app-side +/// nav/tab/network browser bodies), the viewport snapshot capture, WKUserScript +/// injection, and the dialog completion handlers — while every script string, +/// retry loop, payload shape, and piece of selection state lives in the +/// coordinator. Every method is `@MainActor`: the coordinator runs there, so +/// these are plain in-isolation calls (the legacy per-command `v2MainSync` +/// hops disappear). +@MainActor +public protocol ControlBrowserAutomationContext: AnyObject { + /// The single browser DOM-automation state instance (element refs, frame + /// selectors, init scripts/styles, pending dialogs). Owned by the + /// conformer so the app-side browser bodies that still read it + /// (snapshot ref minting, `state.save`/`state.load`, surface cleanup) + /// share it with the coordinator. + var controlBrowserAutomationState: ControlBrowserAutomationState { get } + + /// Resolves the browser panel a DOM-automation command targets, mirroring + /// the legacy `v2BrowserWithPanel` + `v2ResolveBrowserSurfaceId` + /// precedence: explicit surface (`surface_id` ?? `tab_id`), else the + /// selected surface of an explicit `pane_id`, else the workspace's + /// focused surface. + /// + /// - Parameters: + /// - routing: The shared routing selectors (TabManager + workspace). + /// - surfaceID: The explicit browser surface param, if present. + /// - Returns: The resolution outcome. + func controlBrowserResolvePanel( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlBrowserPanelResolution + + /// Resolves the browser panel for `browser.wait`, whose legacy resolution + /// differs: explicit `surface_id` only (no `tab_id`/`pane_id`), else the + /// workspace's focused surface. + /// + /// - Parameters: + /// - routing: The shared routing selectors. + /// - surfaceID: The explicit `surface_id` param, if present. + /// - Returns: The resolution outcome (the pane cases are never produced). + func controlBrowserResolveWaitPanel( + routing: ControlRoutingSelectors, + surfaceID: UUID? + ) -> ControlBrowserPanelResolution + + /// Runs a frame-scoped automation script on a surface's web view: the + /// legacy `v2RunBrowserJavaScript` (frame prelude from the current frame + /// selector, async envelope, page-world with isolated-world retry), with + /// the result bridged through the legacy `v2NormalizeJSValue` rules. + /// + /// - Parameters: + /// - surfaceID: The browser surface. + /// - script: The script source. + /// - timeout: The evaluation timeout in seconds. + /// - useEval: Whether the script runs through `eval(...)` (legacy + /// `useEval: true`) or is inlined as an expression. + /// - Returns: The bridged outcome. + func controlBrowserRunAutomationScript( + surfaceID: UUID, + script: String, + timeout: TimeInterval, + useEval: Bool + ) -> ControlBrowserScriptOutcome + + /// Runs a raw page-world script on a surface's web view (the legacy bare + /// `v2RunJavaScript(..., contentWorld: .page)` used by + /// `browser.dialog.accept`/`dismiss`): no frame prelude, no eval envelope. + /// + /// - Parameters: + /// - surfaceID: The browser surface. + /// - script: The script source. + /// - timeout: The evaluation timeout in seconds. + /// - Returns: The bridged outcome. + func controlBrowserRunPageScript( + surfaceID: UUID, + script: String, + timeout: TimeInterval + ) -> ControlBrowserScriptOutcome + + /// Installs the page-telemetry bootstrap hooks on a surface (the legacy + /// `v2BrowserEnsureTelemetryHooks`, best-effort). + /// + /// - Parameter surfaceID: The browser surface. + func controlBrowserEnsureTelemetryHooks(surfaceID: UUID) + + /// Installs the dialog-telemetry bootstrap hooks on a surface (the legacy + /// `v2BrowserEnsureDialogHooks`, best-effort). + /// + /// - Parameter surfaceID: The browser surface. + func controlBrowserEnsureDialogHooks(surfaceID: UUID) + + /// Captures the surface's visible viewport as PNG data for + /// `browser.screenshot` (the legacy 15s `v2AwaitCallback` around + /// `captureAutomationVisibleViewportSnapshot` + PNG encode). + /// + /// - Parameter surfaceID: The browser surface. + /// - Returns: The capture outcome. + func controlBrowserCaptureScreenshot(surfaceID: UUID) -> ControlBrowserScreenshotResult + + /// Adds a persistent `WKUserScript` (document-start, all frames) to the + /// surface's web view, as `browser.addinitscript`/`browser.addstyle` did. + /// + /// - Parameters: + /// - surfaceID: The browser surface. + /// - source: The script source. + func controlBrowserAddPersistentUserScript(surfaceID: UUID, source: String) + + /// Runs the stored WKWebView completion handler for a pending native + /// dialog popped from ``ControlBrowserAutomationState``, keyed by + /// ``ControlBrowserPendingDialog/dialogID`` (the redesigned transport for + /// the closure the legacy `V2BrowserPendingDialog` carried). + /// + /// - Parameters: + /// - dialogID: The dialog's completion-handler key. + /// - accept: Whether the dialog was accepted. + /// - text: The prompt text, if any. + /// - Returns: Whether a stored completion handler was found and run. + func controlBrowserResolvePendingDialog(dialogID: UUID, accept: Bool, text: String?) -> Bool + + /// The surface's current page title for `browser.get.title` (the legacy + /// `browserPanel.pageTitle` read). + /// + /// - Parameter surfaceID: The browser surface. + /// - Returns: The page title, or `nil` when the surface is no longer a + /// browser. + func controlBrowserPageTitle(surfaceID: UUID) -> String? +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationState.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationState.swift new file mode 100644 index 000000000000..78cf6ed072de --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserAutomationState.swift @@ -0,0 +1,204 @@ +public import Foundation + +/// The browser DOM-automation selection state that used to live as private +/// dictionaries on `TerminalController` (`v2BrowserElementRefs`, +/// `v2BrowserNextElementOrdinal`, `v2BrowserFrameSelectorBySurface`, +/// `v2BrowserInitScriptsBySurface`, `v2BrowserInitStylesBySurface`, +/// `v2BrowserDialogQueueBySurface`), moved behind one owner so the coordinator +/// and the remaining app-side browser bodies (snapshot ref minting, +/// `state.save`/`state.load` frame selectors, surface cleanup) share a single +/// source of truth. +/// +/// `@MainActor` because every reader/writer (the coordinator, the app's +/// browser command bodies, surface cleanup) runs on the main actor, exactly as +/// the legacy controller state did. Exposed to the app through the +/// ``ControlBrowserAutomationContext/controlBrowserAutomationState`` +/// requirement; the conforming controller owns the single instance. +@MainActor +public final class ControlBrowserAutomationState { + private var nextElementOrdinal = 1 + private var elementRefs: [String: ControlBrowserElementRefEntry] = [:] + private var frameSelectorBySurface: [UUID: String] = [:] + private var initScriptsBySurface: [UUID: [String]] = [:] + private var initStylesBySurface: [UUID: [String]] = [:] + private var dialogQueueBySurface: [UUID: [ControlBrowserPendingDialog]] = [:] + + /// Creates empty automation state. + public init() {} + + // MARK: - Element refs + + /// Mints the next `@eN` element ref for a selector resolved on a surface + /// (was `v2BrowserAllocateElementRef`; the ordinal is global, not + /// per-surface, exactly as before). + /// + /// - Parameters: + /// - surfaceID: The browser surface the selector was resolved on. + /// - selector: The CSS selector to pin. + /// - Returns: The minted `@eN` ref token. + public func allocateElementRef(surfaceID: UUID, selector: String) -> String { + let ref = "@e\(nextElementOrdinal)" + nextElementOrdinal += 1 + elementRefs[ref] = ControlBrowserElementRefEntry(surfaceID: surfaceID, selector: selector) + return ref + } + + /// Expands a raw selector param into a CSS selector (was + /// `v2BrowserResolveSelector`): `@eN` (or bare `eN`) refs resolve through + /// the element-ref table and must belong to the same surface; anything + /// else passes through trimmed. Returns `nil` for empty input or an + /// unknown/foreign ref. + /// + /// - Parameters: + /// - rawSelector: The raw `selector`/`ref` param value. + /// - surfaceID: The surface the command targets. + /// - Returns: The CSS selector, or `nil`. + public func resolveSelector(_ 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 = elementRefs[refKey], entry.surfaceID == surfaceID else { return nil } + return entry.selector + } + return trimmed + } + + // MARK: - Frame selectors + + /// The CSS selector of the iframe currently selected on a surface via + /// `browser.frame.select`, if any (was `v2BrowserCurrentFrameSelector`). + /// + /// - Parameter surfaceID: The browser surface. + /// - Returns: The frame selector, or `nil` for the main frame. + public func frameSelector(forSurface surfaceID: UUID) -> String? { + frameSelectorBySurface[surfaceID] + } + + /// Sets or clears the selected-frame selector for a surface + /// (`browser.frame.select` / `browser.frame.main`, and `state.load`). + /// + /// - Parameters: + /// - selector: The frame selector, or `nil` to return to the main frame. + /// - surfaceID: The browser surface. + public func setFrameSelector(_ selector: String?, forSurface surfaceID: UUID) { + if let selector { + frameSelectorBySurface[surfaceID] = selector + } else { + frameSelectorBySurface.removeValue(forKey: surfaceID) + } + } + + // MARK: - Init scripts and styles + + /// Records a `browser.addinitscript` script for a surface. + /// + /// - Parameters: + /// - script: The script source. + /// - surfaceID: The browser surface. + /// - Returns: The new script count (the legacy `scripts` wire field). + public func appendInitScript(_ script: String, forSurface surfaceID: UUID) -> Int { + var scripts = initScriptsBySurface[surfaceID] ?? [] + scripts.append(script) + initScriptsBySurface[surfaceID] = scripts + return scripts.count + } + + /// Records a `browser.addstyle` stylesheet for a surface. + /// + /// - Parameters: + /// - css: The CSS source. + /// - surfaceID: The browser surface. + /// - Returns: The new style count (the legacy `styles` wire field). + public func appendInitStyle(_ css: String, forSurface surfaceID: UUID) -> Int { + var styles = initStylesBySurface[surfaceID] ?? [] + styles.append(css) + initStylesBySurface[surfaceID] = styles + return styles.count + } + + /// The recorded init scripts for a surface, in insertion order. + /// + /// - Parameter surfaceID: The browser surface. + /// - Returns: The scripts, oldest first. + public func initScripts(forSurface surfaceID: UUID) -> [String] { + initScriptsBySurface[surfaceID] ?? [] + } + + /// The recorded init styles for a surface, in insertion order. + /// + /// - Parameter surfaceID: The browser surface. + /// - Returns: The styles, oldest first. + public func initStyles(forSurface surfaceID: UUID) -> [String] { + initStylesBySurface[surfaceID] ?? [] + } + + // MARK: - Pending dialogs + + /// Appends a pending dialog to its surface's FIFO queue, keeping the + /// legacy 16-entry bound (oldest entries drop first). + /// + /// - Parameter dialog: The dialog to enqueue. + /// - Returns: The `dialogID`s of entries dropped by the bound, so the app + /// can release their stored completion handlers (the legacy behavior + /// dropped the closures unrun; releasing unrun is identical). + public func enqueueDialog(_ dialog: ControlBrowserPendingDialog) -> [UUID] { + var queue = dialogQueueBySurface[dialog.surfaceID] ?? [] + queue.append(dialog) + var dropped: [UUID] = [] + if queue.count > 16 { + // Keep bounded memory while preserving FIFO semantics for newest entries. + dropped = queue.prefix(queue.count - 16).map(\.dialogID) + queue.removeFirst(queue.count - 16) + } + dialogQueueBySurface[dialog.surfaceID] = queue + return dropped + } + + /// The pending dialogs for a surface, oldest first (was the queue behind + /// `v2BrowserPendingDialogs`). + /// + /// - Parameter surfaceID: The browser surface. + /// - Returns: The pending dialogs. + public func pendingDialogs(forSurface surfaceID: UUID) -> [ControlBrowserPendingDialog] { + dialogQueueBySurface[surfaceID] ?? [] + } + + /// Removes and returns the oldest pending dialog for a surface (was + /// `v2BrowserPopDialog`). The caller resolves its completion handler via + /// the seam's `controlBrowserResolvePendingDialog(dialogID:accept:text:)`. + /// + /// - Parameter surfaceID: The browser surface. + /// - Returns: The popped dialog, or `nil` when the queue is empty. + public func popDialog(forSurface surfaceID: UUID) -> ControlBrowserPendingDialog? { + var queue = dialogQueueBySurface[surfaceID] ?? [] + guard !queue.isEmpty else { return nil } + let first = queue.removeFirst() + dialogQueueBySurface[surfaceID] = queue + return first + } + + // MARK: - Cleanup + + /// Drops every per-surface entry for a closed surface (the browser slice + /// of the legacy `cleanupSurfaceState(surfaceIds:)`). + /// + /// - Parameter surfaceID: The closed surface. + /// - Returns: The `dialogID`s of dropped pending dialogs, so the app can + /// release their stored completion handlers. + @discardableResult + public func purgeSurfaceState(surfaceID: UUID) -> [UUID] { + frameSelectorBySurface.removeValue(forKey: surfaceID) + initScriptsBySurface.removeValue(forKey: surfaceID) + initStylesBySurface.removeValue(forKey: surfaceID) + let droppedDialogs = (dialogQueueBySurface.removeValue(forKey: surfaceID) ?? []).map(\.dialogID) + elementRefs = elementRefs.filter { $0.value.surfaceID != surfaceID } + return droppedDialogs + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserContext.swift new file mode 100644 index 000000000000..61d5f97cc9c0 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserContext.swift @@ -0,0 +1,410 @@ +public import Foundation + +/// The browser-domain slice of the control-command seam (a constituent of the +/// ``ControlCommandContext`` umbrella), covering the navigation / panel / +/// tabs / network / state half of `browser.*` (the DOM-element automation +/// commands are a separate domain). +/// +/// The app target conforms by reading live `TabManager` / `Workspace` / +/// `BrowserPanel` / `WKWebView` state. Every method is `@MainActor` because +/// the conformer and the coordinator both live on the main actor. +/// +/// No app types cross the seam: the coordinator parses params and builds the +/// wire payloads; the conformance runs the irreducibly app-coupled work +/// (workspace/panel resolution, WKWebView JavaScript, cookie stores, panel +/// actions) and returns Sendable snapshots / resolution enums. Multi-step +/// commands (state save/load, cookies) run inside ONE seam call because the +/// legacy waits pump the run loop — the panel must be captured once, exactly +/// as the legacy `v2BrowserWithPanel` closure did. +@MainActor +public protocol ControlBrowserContext: AnyObject { + // MARK: - open_split / availability / diff viewer + + /// Whether the cmux browser is disabled + /// (`BrowserAvailabilitySettings.isDisabled()`), for `browser.open_split`. + func controlBrowserIsAvailabilityDisabled() -> Bool + + /// Whether the cmux browser is enabled + /// (`BrowserAvailabilitySettings.isEnabled()`), for `browser.tab.new`. + func controlBrowserIsAvailabilityEnabled() -> Bool + + /// Whether the raw `url` param parses to a diff-viewer URL (the legacy + /// `v2IsDiffViewerURL` over the parsed URL; `nil`/unparseable → `false`). + /// + /// - Parameter urlString: The raw `url` param, if any. + /// - Returns: Whether it is a diff-viewer URL. + func controlBrowserIsDiffViewerURL(_ urlString: String?) -> Bool + + /// The browser-disabled external-open fallback shared by + /// `browser.open_split` and `browser.tab.new` (the legacy + /// `v2BrowserDisabledExternalOpenResult`), reusing the surface domain's + /// outcome type. + /// + /// - Parameters: + /// - rawURL: The raw `url` param, if any. + /// - routing: The routing selectors (for the window id in the payload). + /// - Returns: The outcome. + func controlBrowserDisabledExternalOpen( + rawURL: String?, + routing: ControlRoutingSelectors + ) -> ControlSurfaceBrowserDisabledOutcome + + /// Registers the trusted diff-viewer allowlist when the URL is a + /// diff-viewer URL (the legacy `v2RegisterDiffViewerURLIfNeeded`). + /// + /// - Parameters: + /// - urlString: The raw `url` param, if any. + /// - token: The `diff_viewer_token` param, if any. + /// - files: The raw `diff_viewer_files` param, if any. + /// - Returns: The registration outcome. + func controlBrowserRegisterDiffViewer( + urlString: String?, + token: String?, + files: JSONValue? + ) -> ControlBrowserDiffViewerRegistration + + /// Performs the `browser.open_split` main step (external-open rules, + /// window/workspace focusing, split or sibling placement). + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - inputs: The pre-parsed inputs. + /// - Returns: The open-split resolution. + func controlBrowserOpenSplit( + routing: ControlRoutingSelectors, + inputs: ControlBrowserOpenSplitInputs + ) -> ControlBrowserOpenSplitResolution + + // MARK: - navigate / history nav + + /// Navigates a browser surface for `browser.navigate` (`navigateSmart`). + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The target surface. + /// - urlString: The `url` param. + /// - Returns: The nav resolution. + func controlBrowserNavigate( + routing: ControlRoutingSelectors, + surfaceID: UUID, + urlString: String + ) -> ControlBrowserNavResolution + + /// Runs a history action for `browser.back`/`forward`/`reload`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The target surface. + /// - action: The validated action. + /// - Returns: The nav resolution. + func controlBrowserNavAction( + routing: ControlRoutingSelectors, + surfaceID: UUID, + action: ControlBrowserNavAction + ) -> ControlBrowserNavResolution + + // MARK: - focused-browser actions + + /// Toggles React Grab for `browser.react_grab.toggle`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - browserSurfaceID: The explicit `surface_id`, if any. + /// - returnSurfaceID: The explicit `return_to`, if any. + /// - Returns: The toggle resolution. + func controlBrowserReactGrabToggle( + routing: ControlRoutingSelectors, + browserSurfaceID: UUID?, + returnSurfaceID: UUID? + ) -> ControlBrowserReactGrabResolution + + /// Toggles developer tools for `browser.devtools.toggle`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - target: The focused-action target. + /// - Returns: The handled resolution. + func controlBrowserDevToolsToggle( + routing: ControlRoutingSelectors, + target: ControlBrowserFocusedActionTarget + ) -> ControlBrowserHandledResolution + + /// Shows the developer-tools console for `browser.console.show`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - target: The focused-action target. + /// - Returns: The handled resolution. + func controlBrowserConsoleShow( + routing: ControlRoutingSelectors, + target: ControlBrowserFocusedActionTarget + ) -> ControlBrowserHandledResolution + + /// Sets browser focus mode for `browser.focus_mode.set` (focusing the + /// target panel first when activating, as the legacy body did). + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - target: The focused-action target. + /// - action: The validated mode action. + /// - Returns: The handled resolution. + func controlBrowserFocusModeSet( + routing: ControlRoutingSelectors, + target: ControlBrowserFocusedActionTarget, + action: ControlBrowserFocusModeAction + ) -> ControlBrowserHandledResolution + + /// Adjusts zoom for `browser.zoom.set`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - target: The focused-action target. + /// - direction: The validated direction. + /// - Returns: The handled resolution. + func controlBrowserZoomSet( + routing: ControlRoutingSelectors, + target: ControlBrowserFocusedActionTarget, + direction: ControlBrowserZoomDirection + ) -> ControlBrowserHandledResolution + + // MARK: - history / url / web view focus + + /// Clears the default profile's browser history for + /// `browser.history.clear` (`BrowserHistoryStore.shared.clearHistory()`). + func controlBrowserClearDefaultProfileHistory() + + /// Reads a browser surface's current URL for `browser.url.get`. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The target surface. + /// - Returns: The URL resolution. + func controlBrowserCurrentURL( + routing: ControlRoutingSelectors, + surfaceID: UUID + ) -> ControlBrowserURLResolution + + /// Moves first responder into the web view for `browser.focus_webview` + /// (window/workspace activation + omnibar autofocus suppression first, as + /// the legacy body did). + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The target surface. + /// - Returns: The focus resolution. + func controlBrowserFocusWebView( + routing: ControlRoutingSelectors, + surfaceID: UUID + ) -> ControlBrowserFocusWebViewResolution + + /// Whether first responder is inside the web view for + /// `browser.is_webview_focused` (`false` when the surface/panel/window + /// does not resolve, as the legacy body did). + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The target surface. + /// - Returns: Whether the web view is focused. + func controlBrowserIsWebViewFocused( + routing: ControlRoutingSelectors, + surfaceID: UUID + ) -> Bool + + // MARK: - script execution / element refs + + /// Resolves the target browser surface and runs a coordinator-built + /// script, returning the normalized outcome. Backs `browser.snapshot`, + /// `browser.storage.*`, `browser.console.list/clear`, and + /// `browser.errors.list`. + /// + /// - Parameters: + /// - target: The browser surface target. + /// - script: The script source. + /// - timeout: The legacy timeout in seconds. + /// - mode: Which legacy execution primitive to use. + /// - Returns: The script resolution. + func controlBrowserRunScript( + target: ControlBrowserSurfaceTarget, + script: String, + timeout: Double, + mode: ControlBrowserScriptMode + ) -> ControlBrowserScriptResolution + + // MARK: - cookies + + /// Reads all cookies from the resolved panel's store for + /// `browser.cookies.get` (the coordinator filters and serializes). + /// + /// - Parameter target: The browser surface target. + /// - Returns: The cookies resolution. + func controlBrowserCookiesGet( + target: ControlBrowserSurfaceTarget + ) -> ControlBrowserCookiesGetResolution + + /// Writes cookie rows to the resolved panel's store for + /// `browser.cookies.set`, in row order, stopping at the first invalid row + /// or timeout (legacy behavior). + /// + /// - Parameters: + /// - target: The browser surface target. + /// - rows: The coordinator-built cookie rows (JSON objects). + /// - Returns: The set resolution. + func controlBrowserCookiesSet( + target: ControlBrowserSurfaceTarget, + rows: [JSONValue] + ) -> ControlBrowserCookiesSetResolution + + /// Deletes matching cookies from the resolved panel's store for + /// `browser.cookies.clear`. + /// + /// - Parameters: + /// - target: The browser surface target. + /// - name: The `name` filter, if any. + /// - domain: The `domain` filter, if any. + /// - hasAllParam: Whether an `all` param was present (affects the legacy + /// clear-all default). + /// - Returns: The clear resolution. + func controlBrowserCookiesClear( + target: ControlBrowserSurfaceTarget, + name: String?, + domain: String?, + hasAllParam: Bool + ) -> ControlBrowserCookiesClearResolution + + // MARK: - state save / load + + /// Captures URL + cookies + storage + frame selector in one resolved-panel + /// pass for `browser.state.save` (the coordinator writes the file). + /// + /// - Parameters: + /// - target: The browser surface target. + /// - storageScript: The coordinator-built storage readout script. + /// - Returns: The capture resolution. + func controlBrowserStateCapture( + target: ControlBrowserSurfaceTarget, + storageScript: String + ) -> ControlBrowserStateCaptureResolution + + /// Applies a loaded state file in one resolved-panel pass for + /// `browser.state.load`: frame selector, then navigation, then cookies, + /// then the storage script (the legacy order, best-effort as legacy). + /// + /// - Parameters: + /// - target: The browser surface target. + /// - frameSelector: The non-empty `frame_selector`, or `nil` to clear. + /// - navigateToURLString: The non-empty `url` to navigate to, if any + /// (the app parses it; unparseable URLs are skipped, as legacy). + /// - cookieRows: The raw cookie rows from the state file. + /// - storageScript: The coordinator-built storage apply script, if the + /// file carried a storage object. + /// - Returns: The apply resolution. + func controlBrowserStateApply( + target: ControlBrowserSurfaceTarget, + frameSelector: String?, + navigateToURLString: String?, + cookieRows: [JSONValue], + storageScript: String? + ) -> ControlBrowserStateApplyResolution + + // MARK: - tabs + + /// Snapshots the workspace's browser tabs for `browser.tab.list`. + /// + /// - Parameter routing: The routing selectors. + /// - Returns: The snapshot, or `nil` when no workspace resolves. + func controlBrowserTabList(routing: ControlRoutingSelectors) -> ControlBrowserTabListSnapshot? + + /// Creates a browser tab for `browser.tab.new`, walking the legacy pane + /// fallback ladder. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - urlString: The raw `url` param, if any (the app parses it). + /// - explicitPaneID: `pane_id` or `target_pane_id`, if any. + /// - paneFromSurfaceID: The `surface_id` whose pane is the fallback + /// target, if any. + /// - Returns: The creation resolution. + func controlBrowserTabNew( + routing: ControlRoutingSelectors, + urlString: String?, + explicitPaneID: UUID?, + paneFromSurfaceID: UUID? + ) -> ControlBrowserTabNewResolution + + /// Focuses a browser tab for `browser.tab.switch`, walking the legacy + /// explicit-id / index / `surface_id` ladder. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - explicitID: `target_surface_id` or `tab_id`, if any. + /// - index: The `index` param, if any. + /// - surfaceID: The `surface_id` param, if any. + /// - Returns: The switch resolution. + func controlBrowserTabSwitch( + routing: ControlRoutingSelectors, + explicitID: UUID?, + index: Int?, + surfaceID: UUID? + ) -> ControlBrowserTabSwitchResolution + + /// Closes a browser tab for `browser.tab.close`, walking the legacy + /// explicit-id / index / `surface_id` / focused ladder. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - explicitID: `target_surface_id` or `tab_id`, if any. + /// - index: The `index` param, if any. + /// - surfaceID: The `surface_id` param, if any. + /// - Returns: The close resolution. + func controlBrowserTabClose( + routing: ControlRoutingSelectors, + explicitID: UUID?, + index: Int?, + surfaceID: UUID? + ) -> ControlBrowserTabCloseResolution + + // MARK: - unsupported-network bookkeeping + + /// Records a `browser.network.route`/`unroute` attempt in the per-surface + /// unsupported-request log (the legacy `v2BrowserRecordUnsupportedRequest`, + /// bounded to 256 entries; the log lives app-side so the surface-close + /// cleanup keeps working). + /// + /// - Parameters: + /// - surfaceID: The surface the attempt targeted. + /// - request: The recorded request object. + func controlBrowserRecordUnsupportedRequest(surfaceID: UUID, request: JSONValue) + + /// The recorded unsupported-request log for `browser.network.requests`. + /// + /// - Parameter surfaceID: The surface to read. + /// - Returns: The recorded request objects, oldest first. + func controlBrowserUnsupportedRequests(surfaceID: UUID) -> [JSONValue] + + // MARK: - import dialog + + /// Resolves the `destination_profile` query against the app's browser + /// profiles for `browser.import.dialog` (UUID, then display name/slug, + /// then optional creation — the legacy ladder). + /// + /// - Parameters: + /// - query: The non-empty query. + /// - createIfMissing: Whether `create_destination_profile`/ + /// `create_profile` was true. + /// - Returns: The profile resolution. + func controlBrowserImportResolveDestinationProfile( + query: String, + createIfMissing: Bool + ) -> ControlBrowserImportProfileResolution + + /// Schedules presentation of the browser data import dialog for + /// `browser.import.dialog` (the legacy `Task { @MainActor … }` hop). + /// + /// - Parameters: + /// - scope: The validated scope, if any. + /// - destinationProfileID: The resolved destination profile, if any. + func controlBrowserImportPresentDialog( + scope: ControlBrowserImportScope?, + destinationProfileID: UUID? + ) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookie.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookie.swift new file mode 100644 index 000000000000..e2b46d135f42 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookie.swift @@ -0,0 +1,49 @@ +public import Foundation + +/// A Sendable snapshot of one `HTTPCookie`, carrying exactly the fields the +/// legacy `v2BrowserCookieDict` serialized. +public struct ControlBrowserCookie: Sendable, Equatable { + /// `HTTPCookie.name`. + public let name: String + /// `HTTPCookie.value`. + public let value: String + /// `HTTPCookie.domain`. + public let domain: String + /// `HTTPCookie.path`. + public let path: String + /// `HTTPCookie.isSecure`. + public let isSecure: Bool + /// `HTTPCookie.isSessionOnly`. + public let isSessionOnly: Bool + /// `Int(expiresDate.timeIntervalSince1970)` when present (the legacy + /// truncation), or `nil` → wire `null`. + public let expiresEpoch: Int64? + + /// Creates a cookie snapshot. + /// + /// - Parameters: + /// - name: The cookie name. + /// - value: The cookie value. + /// - domain: The cookie domain. + /// - path: The cookie path. + /// - isSecure: Whether the cookie is secure-only. + /// - isSessionOnly: Whether the cookie is session-only. + /// - expiresEpoch: The truncated expiry epoch seconds, if any. + public init( + name: String, + value: String, + domain: String, + path: String, + isSecure: Bool, + isSessionOnly: Bool, + expiresEpoch: Int64? + ) { + self.name = name + self.value = value + self.domain = domain + self.path = path + self.isSecure = isSecure + self.isSessionOnly = isSessionOnly + self.expiresEpoch = expiresEpoch + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesClearResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesClearResolution.swift new file mode 100644 index 000000000000..20c38b728e67 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesClearResolution.swift @@ -0,0 +1,10 @@ +/// The outcome of the app-side `browser.cookies.clear` deletion pass. +public enum ControlBrowserCookiesClearResolution: Sendable, Equatable { + /// The browser surface did not resolve. + case failure(ControlBrowserPanelFailure) + /// The cookie store read timed out (legacy `timeout` / + /// "Timed out reading cookies"). + case timedOut + /// The matching cookies were deleted. + case cleared(identity: ControlBrowserPanelIdentity, removed: Int) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesGetResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesGetResolution.swift new file mode 100644 index 000000000000..fb0075a9eec2 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesGetResolution.swift @@ -0,0 +1,10 @@ +/// The outcome of the app-side `browser.cookies.get` read. +public enum ControlBrowserCookiesGetResolution: Sendable, Equatable { + /// The browser surface did not resolve. + case failure(ControlBrowserPanelFailure) + /// The cookie store read timed out (legacy `timeout` / + /// "Timed out reading cookies"). + case timedOut + /// All cookies in the panel's store (the coordinator filters). + case cookies(identity: ControlBrowserPanelIdentity, cookies: [ControlBrowserCookie]) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesSetResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesSetResolution.swift new file mode 100644 index 000000000000..6550ca0a9577 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserCookiesSetResolution.swift @@ -0,0 +1,18 @@ +/// The outcome of the app-side `browser.cookies.set` write, preserving the +/// legacy in-closure ordering: panel resolution first, then the empty-payload +/// check, then per-row validation/writes. +public enum ControlBrowserCookiesSetResolution: Sendable, Equatable { + /// The browser surface did not resolve. + case failure(ControlBrowserPanelFailure) + /// No cookie rows after parsing (legacy `invalid_params` / + /// "Missing cookies payload"). + case emptyPayload + /// A row did not build an `HTTPCookie` (legacy `invalid_params` / + /// "Invalid cookie payload", `data: {"cookie": row}`). + case invalidCookie(row: JSONValue) + /// A store write timed out (legacy `timeout` / "Timed out setting cookie", + /// `data: {"name": …}`). + case timedOutSetting(name: String) + /// All rows wrote. + case set(identity: ControlBrowserPanelIdentity, count: Int) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserDiffViewerRegistration.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserDiffViewerRegistration.swift new file mode 100644 index 000000000000..f885847b9251 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserDiffViewerRegistration.swift @@ -0,0 +1,18 @@ +/// The outcome of the legacy `v2RegisterDiffViewerURLIfNeeded` step of +/// `browser.open_split`. Each failure case maps onto exactly one legacy +/// `invalid_params` error shape. +public enum ControlBrowserDiffViewerRegistration: Sendable, Equatable { + /// Not a diff-viewer URL (or no URL): nothing to register. + case notApplicable + /// The trusted allowlist registered successfully. + case registered + /// Token/files guard failed (legacy "Missing or invalid trusted diff + /// viewer allowlist", `data: nil`). + case missingOrInvalidAllowlist + /// Some file entries were invalid (legacy "Invalid trusted diff viewer + /// allowlist", `data: nil`). + case invalidAllowlist + /// Registration threw (legacy "Invalid trusted diff viewer allowlist", + /// `data: {"details": …}`). + case invalidAllowlistDetails(String) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserElementRefEntry.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserElementRefEntry.swift new file mode 100644 index 000000000000..9725acc35286 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserElementRefEntry.swift @@ -0,0 +1,22 @@ +public import Foundation + +/// One minted `@eN` element handle (was the controller-private +/// `V2BrowserElementRefEntry`): the CSS selector a `browser.find.*` call (or a +/// snapshot) resolved, pinned to the browser surface it was resolved on so a +/// stale ref can never act on another surface's DOM. +public struct ControlBrowserElementRefEntry: Sendable, Equatable { + /// The browser surface the selector was resolved on. + public let surfaceID: UUID + /// The CSS selector the `@eN` ref expands to. + public let selector: String + + /// Creates an element-ref entry. + /// + /// - Parameters: + /// - surfaceID: The browser surface the selector was resolved on. + /// - selector: The CSS selector the ref expands to. + public init(surfaceID: UUID, selector: String) { + self.surfaceID = surfaceID + self.selector = selector + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusModeAction.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusModeAction.swift new file mode 100644 index 000000000000..86c9c0642ab1 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusModeAction.swift @@ -0,0 +1,11 @@ +/// The validated `browser.focus_mode.set` action, after the coordinator maps +/// the legacy mode aliases (`enter/on/true/active`, `exit/off/false/inactive`, +/// `toggle`). +public enum ControlBrowserFocusModeAction: Sendable, Equatable { + /// One of the enter aliases. + case activate + /// One of the exit aliases. + case deactivate + /// `toggle`. + case toggle +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusWebViewResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusWebViewResolution.swift new file mode 100644 index 000000000000..6d10422d47d3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusWebViewResolution.swift @@ -0,0 +1,17 @@ +/// The outcome of `browser.focus_webview`, preserving the legacy body's four +/// distinct failures. +public enum ControlBrowserFocusWebViewResolution: Sendable, Equatable { + /// The workspace or browser panel did not resolve (legacy `not_found` / + /// "Surface not found or not a browser"). + case notFoundOrNotBrowser + /// The web view has no window (legacy `invalid_state` / + /// "WebView is not in a window"). + case webViewNotInWindow + /// The web view is hidden (legacy `invalid_state` / "WebView is hidden"). + case webViewHidden + /// First responder did not land inside the web view (legacy + /// `internal_error` / "Focus did not move into web view"). + case focusDidNotMove + /// Focus moved into the web view (legacy `.ok({"focused": true})`). + case focused +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusedActionTarget.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusedActionTarget.swift new file mode 100644 index 000000000000..100aa30aa9d4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserFocusedActionTarget.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The target of a focused-browser action (`browser.devtools.toggle`, +/// `browser.console.show`, `browser.focus_mode.set`, `browser.zoom.set`), +/// mirroring the legacy `v2ResolveBrowserPanelForFocusedAction` inputs: a +/// SUPPLIED `surface_id` is authoritative (even when unresolvable), while a +/// genuinely absent one falls back to the focused/sole browser. +public struct ControlBrowserFocusedActionTarget: Sendable, Equatable { + /// Whether a non-null `surface_id` param was present at all. + public let hasSurfaceParam: Bool + /// The resolved `surface_id`, if it parsed/resolved. + public let surfaceID: UUID? + + /// Creates a focused-action target. + /// + /// - Parameters: + /// - hasSurfaceParam: Whether a non-null `surface_id` param was present. + /// - surfaceID: The resolved `surface_id`, if any. + public init(hasSurfaceParam: Bool, surfaceID: UUID?) { + self.hasSurfaceParam = hasSurfaceParam + self.surfaceID = surfaceID + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserHandledResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserHandledResolution.swift new file mode 100644 index 000000000000..363013c1ac68 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserHandledResolution.swift @@ -0,0 +1,12 @@ +public import Foundation + +/// The outcome of a focused-browser action: the legacy bodies returned a +/// single default `not_found` ("No browser surface found") when the workspace +/// or target browser did not resolve, or the identity payload plus the +/// panel-reported `handled` flag. +public enum ControlBrowserHandledResolution: Sendable, Equatable { + /// No workspace or no target browser (legacy `not_found`). + case notFound + /// The action ran on the resolved browser. + case acted(workspaceID: UUID, surfaceID: UUID, windowID: UUID?, handled: Bool) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportProfileResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportProfileResolution.swift new file mode 100644 index 000000000000..0604045f37ee --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportProfileResolution.swift @@ -0,0 +1,14 @@ +public import Foundation + +/// The outcome of resolving the `browser.import.dialog` +/// `destination_profile` query against the app's browser profiles. +public enum ControlBrowserImportProfileResolution: Sendable, Equatable { + /// The query matched (or created) a profile. + case resolved(UUID) + /// Creation was requested but failed (legacy `invalid_params` / + /// "destination_profile could not be created"). + case createFailed + /// No match and no creation requested (legacy `invalid_params` / + /// "destination_profile does not match a cmux browser profile"). + case noMatch +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportScope.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportScope.swift new file mode 100644 index 000000000000..8192895d290b --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserImportScope.swift @@ -0,0 +1,13 @@ +/// The validated `browser.import.dialog` scope, mirroring the app's +/// `BrowserImportScope` cases. `rawValue` matches the app enum exactly so the +/// wire payload (`"scope": rawValue`) is byte-identical. +public enum ControlBrowserImportScope: String, Sendable, Equatable { + /// Cookies only. + case cookiesOnly + /// History only. + case historyOnly + /// Cookies and history. + case cookiesAndHistory + /// Everything. + case everything +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavAction.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavAction.swift new file mode 100644 index 000000000000..8c4b93b6dbf4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavAction.swift @@ -0,0 +1,11 @@ +/// The simple history navigation a `browser.back`/`forward`/`reload` command +/// performs on the target browser panel (the legacy `v2BrowserNavSimple` +/// action string). +public enum ControlBrowserNavAction: Sendable, Equatable { + /// `browser.back` → `goBack()`. + case back + /// `browser.forward` → `goForward()`. + case forward + /// `browser.reload` → `reload()`. + case reload +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavResolution.swift new file mode 100644 index 000000000000..6b864cd9da33 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserNavResolution.swift @@ -0,0 +1,12 @@ +public import Foundation + +/// The outcome of `browser.navigate` and the simple nav actions: the legacy +/// bodies returned a single default `not_found` when workspace or panel did +/// not resolve, or the identity payload after acting. +public enum ControlBrowserNavResolution: Sendable, Equatable { + /// The workspace or browser panel did not resolve (legacy `not_found` / + /// "Surface not found or not a browser"). + case notFoundOrNotBrowser + /// The action ran; carries the ids the payload needs. + case ok(workspaceID: UUID, windowID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitInputs.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitInputs.swift new file mode 100644 index 000000000000..c238b93301aa --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitInputs.swift @@ -0,0 +1,50 @@ +public import Foundation + +/// The pre-parsed inputs for `browser.open_split`, mirroring the legacy +/// `v2BrowserOpenSplit` param reads. +public struct ControlBrowserOpenSplitInputs: Sendable, Equatable { + /// The raw `url` param (trimmed, non-empty), if any. The app parses it. + public let urlString: String? + /// `respect_external_open_rules` (default `false`). + public let respectExternalOpenRules: Bool + /// The explicit `surface_id` source, if any. + public let sourceSurfaceID: UUID? + /// The requested `focus` (default `false`); the app applies its + /// focus-allowance policy (`v2FocusAllowed`). + public let focusRequested: Bool + /// `show_omnibar` (default `true`). + public let showOmnibar: Bool + /// `transparent_background` (default `false`). + public let transparentBackground: Bool + /// The explicit `bypass_remote_proxy` param, or `nil` to let the app + /// default it to the diff-viewer-URL check (legacy behavior). + public let bypassRemoteProxy: Bool? + + /// Creates open-split inputs. + /// + /// - Parameters: + /// - urlString: The raw `url` param, if any. + /// - respectExternalOpenRules: `respect_external_open_rules`. + /// - sourceSurfaceID: The explicit `surface_id` source, if any. + /// - focusRequested: The requested `focus`. + /// - showOmnibar: `show_omnibar`. + /// - transparentBackground: `transparent_background`. + /// - bypassRemoteProxy: The explicit `bypass_remote_proxy`, if present. + public init( + urlString: String?, + respectExternalOpenRules: Bool, + sourceSurfaceID: UUID?, + focusRequested: Bool, + showOmnibar: Bool, + transparentBackground: Bool, + bypassRemoteProxy: Bool? + ) { + self.urlString = urlString + self.respectExternalOpenRules = respectExternalOpenRules + self.sourceSurfaceID = sourceSurfaceID + self.focusRequested = focusRequested + self.showOmnibar = showOmnibar + self.transparentBackground = transparentBackground + self.bypassRemoteProxy = bypassRemoteProxy + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitResolution.swift new file mode 100644 index 000000000000..a14fd7ba053b --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserOpenSplitResolution.swift @@ -0,0 +1,88 @@ +public import Foundation + +/// The outcome of the app-side `browser.open_split` perform step, mirroring +/// the legacy `v2BrowserOpenSplit` main-sync block's distinct results. +public enum ControlBrowserOpenSplitResolution: Sendable, Equatable { + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// The external-open path failed (legacy `external_open_failed`). + case externalOpenFailed(url: String) + /// The URL opened externally per the link-open rules (legacy `.ok` with + /// `placement_strategy: "external"`). + case openedExternally(windowID: UUID?, workspaceID: UUID, url: String) + /// No focused surface to split (legacy `not_found`). + case noFocusedSurface + /// The explicit source surface does not exist (legacy `not_found`, + /// `data: {"surface_id": …}`). + case sourceSurfaceNotFound(surfaceID: UUID) + /// Browser creation failed (legacy `internal_error`). + case createFailed + /// The browser surface was created. + case created(Snapshot) + + /// The created-browser payload fields, byte-faithful to the legacy result. + public struct Snapshot: Sendable, Equatable { + /// The enclosing window id, if resolved. + public let windowID: UUID? + /// The owning workspace id. + public let workspaceID: UUID + /// The created surface's pane id, if resolved. + public let paneID: UUID? + /// The created browser surface id. + public let surfaceID: UUID + /// The split-source surface id. + public let sourceSurfaceID: UUID + /// The split-source surface's pane id, if resolved. + public let sourcePaneID: UUID? + /// Whether a new split was created (vs reusing a right sibling pane). + public let createdSplit: Bool + /// `"split_right"` or `"reuse_right_sibling"`. + public let placementStrategy: String + /// The effective omnibar visibility of the created panel. + public let showOmnibar: Bool + /// The `transparent_background` value used. + public let transparentBackground: Bool + /// The effective `bypass_remote_proxy` value used. + public let bypassRemoteProxy: Bool + + /// Creates an open-split snapshot. + /// + /// - Parameters: + /// - windowID: The enclosing window id, if resolved. + /// - workspaceID: The owning workspace id. + /// - paneID: The created surface's pane id, if resolved. + /// - surfaceID: The created browser surface id. + /// - sourceSurfaceID: The split-source surface id. + /// - sourcePaneID: The source surface's pane id, if resolved. + /// - createdSplit: Whether a new split was created. + /// - placementStrategy: The placement strategy string. + /// - showOmnibar: The effective omnibar visibility. + /// - transparentBackground: The transparent-background value used. + /// - bypassRemoteProxy: The effective bypass value used. + public init( + windowID: UUID?, + workspaceID: UUID, + paneID: UUID?, + surfaceID: UUID, + sourceSurfaceID: UUID, + sourcePaneID: UUID?, + createdSplit: Bool, + placementStrategy: String, + showOmnibar: Bool, + transparentBackground: Bool, + bypassRemoteProxy: Bool + ) { + self.windowID = windowID + self.workspaceID = workspaceID + self.paneID = paneID + self.surfaceID = surfaceID + self.sourceSurfaceID = sourceSurfaceID + self.sourcePaneID = sourcePaneID + self.createdSplit = createdSplit + self.placementStrategy = placementStrategy + self.showOmnibar = showOmnibar + self.transparentBackground = transparentBackground + self.bypassRemoteProxy = bypassRemoteProxy + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelContext.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelContext.swift new file mode 100644 index 000000000000..b10e2228bdf3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelContext.swift @@ -0,0 +1,56 @@ +public import Foundation + +/// The browser-panel slice of the control-command seam (a constituent of the +/// ``ControlCommandContext`` umbrella): live app reach for the v1 line-protocol +/// browser commands (`open_browser` / `navigate` / `browser_back` / +/// `browser_forward` / `browser_reload` / `get_url` / `focus_webview` / +/// `is_webview_focused`) plus the browser-availability reads the v1 pane and +/// surface creation commands share. Distinct from ``ControlBrowserContext``, +/// which serves the v2 `browser.*` methods. +/// +/// `@MainActor` because its conformer lives on the main actor and the +/// coordinator runs there too. +@MainActor +public protocol ControlBrowserPanelContext: AnyObject { + /// Whether the active `TabManager` is wired (the legacy + /// `guard let tabManager` head of every browser v1 body). + func controlBrowserPanelTabManagerAvailable() -> Bool + + /// Whether the embedded cmux browser is enabled + /// (`BrowserAvailabilitySettings.isEnabled()`; disabled is its negation). + func controlBrowserPanelAvailabilityEnabled() -> Bool + + /// Opens a URL in the external default browser (the disabled-browser + /// fallback), returning whether the open succeeded. + func controlBrowserPanelOpenURLExternally(_ url: URL) -> Bool + + /// Creates a browser split off the selected workspace's focused panel for + /// `open_browser` (focus allowance read app-side from the active + /// socket-command policy). Returns the new panel id, or `nil` on failure. + func controlBrowserPanelOpen(url: URL?) -> UUID? + + /// Smart-navigates a browser panel (`navigate`); `false` when the panel + /// does not resolve to a browser of the selected workspace. + func controlBrowserPanelNavigate(panelID: UUID, urlString: String) -> Bool + + /// Navigates back (`browser_back`); `false` when the panel does not + /// resolve. + func controlBrowserPanelGoBack(panelID: UUID) -> Bool + + /// Navigates forward (`browser_forward`); `false` when the panel does not + /// resolve. + func controlBrowserPanelGoForward(panelID: UUID) -> Bool + + /// Reloads (`browser_reload`); `false` when the panel does not resolve. + func controlBrowserPanelReload(panelID: UUID) -> Bool + + /// The current URL absolute string (`get_url`), an empty string when the + /// panel has no URL, or `nil` when the panel does not resolve. + func controlBrowserPanelCurrentURLString(panelID: UUID) -> String? + + /// Moves first responder into the web view (`focus_webview`). + func controlBrowserPanelFocusWebView(panelID: UUID) -> ControlBrowserPanelFocusWebViewResolution + + /// Whether the web view holds focus (`is_webview_focused`). + func controlBrowserPanelIsWebViewFocused(panelID: UUID) -> ControlBrowserPanelWebViewFocusState +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFailure.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFailure.swift new file mode 100644 index 000000000000..d3bde22fa76a --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFailure.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The failure ladder of the legacy `v2BrowserWithPanel` browser-surface +/// resolution. Each case maps onto exactly one legacy error result; the +/// coordinator owns that mapping so the wire bytes match. +public enum ControlBrowserPanelFailure: Sendable, Equatable { + /// No TabManager resolved (legacy `unavailable` / "TabManager not available"). + case tabManagerUnavailable + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// An explicit `pane_id` did not resolve to a pane (legacy `not_found` / + /// "Pane not found", `data: {"pane_id": …}`). + case paneNotFound(paneID: UUID) + /// The explicit pane has no selected surface (legacy `not_found` / + /// "Pane has no selected surface", `data: {"pane_id": …}`). + case paneHasNoSelectedSurface(paneID: UUID) + /// No explicit target and no focused surface (legacy `not_found` / + /// "No focused browser surface"). + case noFocusedBrowserSurface + /// The resolved surface is not a browser (legacy `invalid_params` / + /// "Surface is not a browser", `data: {"surface_id": …}`). + case surfaceNotBrowser(surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFocusWebViewResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFocusWebViewResolution.swift new file mode 100644 index 000000000000..f3415d3fbec6 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelFocusWebViewResolution.swift @@ -0,0 +1,17 @@ +internal import Foundation + +/// The outcome of the v1 line-protocol `focus_webview` command, preserving +/// each legacy error string (distinct from the v2 `browser.focus_webview` +/// resolution). +public enum ControlBrowserPanelFocusWebViewResolution: Sendable, Equatable { + /// The panel did not resolve to a browser panel of the selected workspace. + case panelNotFound + /// The web view is not attached to a window. + case webViewNotInWindow + /// The web view (or an ancestor) is hidden. + case webViewHidden + /// First responder did not land inside the web view. + case focusDidNotMove + /// Focus moved into the web view. + case focused +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelIdentity.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelIdentity.swift new file mode 100644 index 000000000000..e39dd537d58d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelIdentity.swift @@ -0,0 +1,21 @@ +public import Foundation + +/// The resolved workspace/surface identity a successful browser-surface +/// resolution carries back to the coordinator (which mints the matching +/// `workspace_ref`/`surface_ref` for the payload). +public struct ControlBrowserPanelIdentity: Sendable, Equatable { + /// The owning workspace id. + public let workspaceID: UUID + /// The resolved browser surface id. + public let surfaceID: UUID + + /// Creates a panel identity. + /// + /// - Parameters: + /// - workspaceID: The owning workspace id. + /// - surfaceID: The resolved browser surface id. + public init(workspaceID: UUID, surfaceID: UUID) { + self.workspaceID = workspaceID + self.surfaceID = surfaceID + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelResolution.swift new file mode 100644 index 000000000000..473697e57d5d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelResolution.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The outcome of resolving the browser panel a DOM-automation command targets +/// (the legacy `v2BrowserWithPanel` / `v2ResolveBrowserSurfaceId` precedence), +/// with one case per distinct legacy failure so the coordinator can preserve +/// every error code/message/data shape exactly. +public enum ControlBrowserPanelResolution: Sendable, Equatable { + /// No `TabManager` could be resolved (`unavailable`). + case tabManagerUnavailable + /// The routed workspace was not found (`not_found`). + case workspaceNotFound + /// An explicit `pane_id` did not match a pane (`not_found` + `pane_id`). + case paneNotFound(UUID) + /// The pane exists but has no selected surface (`not_found` + `pane_id`). + case paneHasNoSelectedSurface(UUID) + /// No explicit surface and the workspace has no focused surface + /// (`not_found`). + case noFocusedBrowserSurface + /// The resolved surface is not a browser (`invalid_params` + `surface_id`). + case surfaceNotBrowser(UUID) + /// The target browser surface, with its owning workspace. + case resolved(workspaceID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelWebViewFocusState.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelWebViewFocusState.swift new file mode 100644 index 000000000000..80380350d51e --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPanelWebViewFocusState.swift @@ -0,0 +1,10 @@ +internal import Foundation + +/// The outcome of the v1 line-protocol `is_webview_focused` query. +public enum ControlBrowserPanelWebViewFocusState: Sendable, Equatable { + /// The panel did not resolve to a browser panel of the selected workspace. + case panelNotFound + /// Whether the web view holds focus (`false` also covers a detached web + /// view, matching the legacy reply). + case focused(Bool) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPendingDialog.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPendingDialog.swift new file mode 100644 index 000000000000..881370931ebe --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserPendingDialog.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// One pending native browser dialog (was the controller-private +/// `V2BrowserPendingDialog`), redesigned as a `Sendable` value: instead of +/// carrying the WKWebView completion handler as a closure, it carries a +/// `dialogID` key. The app side keeps the completion handler keyed by that id +/// and runs it when the accept/dismiss decision comes back through +/// `ControlBrowserAutomationContext.controlBrowserResolvePendingDialog(dialogID:accept:text:)`. +public struct ControlBrowserPendingDialog: Sendable, Equatable { + /// The key the app side stores the WKWebView completion handler under. + public let dialogID: UUID + /// The browser surface that raised the dialog. + public let surfaceID: UUID + /// The dialog kind (the legacy `type` wire field: `alert`, `confirm`, + /// `prompt`, …). + public let kind: String + /// The dialog message text. + public let message: String + /// The prompt's default text, if the dialog is a prompt. + public let defaultText: String? + + /// Creates a pending-dialog value. + /// + /// - Parameters: + /// - dialogID: The completion-handler key. + /// - surfaceID: The browser surface that raised the dialog. + /// - kind: The dialog kind (legacy `type` field). + /// - message: The dialog message text. + /// - defaultText: The prompt's default text, if any. + public init(dialogID: UUID, surfaceID: UUID, kind: String, message: String, defaultText: String?) { + self.dialogID = dialogID + self.surfaceID = surfaceID + self.kind = kind + self.message = message + self.defaultText = defaultText + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserReactGrabResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserReactGrabResolution.swift new file mode 100644 index 000000000000..9394a3c17940 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserReactGrabResolution.swift @@ -0,0 +1,11 @@ +public import Foundation + +/// The outcome of `browser.react_grab.toggle`: the legacy body returned a +/// single default `not_found` ("No browser surface to toggle React Grab on") +/// when the workspace or toggle target did not resolve. +public enum ControlBrowserReactGrabResolution: Sendable, Equatable { + /// No workspace, or `toggleReactGrab` found no browser (legacy `not_found`). + case notFound + /// React Grab toggled on the acted browser. + case toggled(workspaceID: UUID, surfaceID: UUID, windowID: UUID?) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScreenshotResult.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScreenshotResult.swift new file mode 100644 index 000000000000..2b8b92710345 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScreenshotResult.swift @@ -0,0 +1,15 @@ +public import Foundation + +/// The outcome of capturing a browser surface screenshot for +/// `browser.screenshot`, preserving the legacy timeout vs capture-failure +/// error distinction. +public enum ControlBrowserScreenshotResult: Sendable, Equatable { + /// The PNG-encoded viewport snapshot. + case png(Data) + /// The snapshot callback never completed within the legacy 15s budget + /// (`timeout` / "Timed out waiting for snapshot"). + case timedOut + /// The capture or PNG encode failed (`internal_error` / "Failed to + /// capture snapshot"). + case captureFailed +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptMode.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptMode.swift new file mode 100644 index 000000000000..ca26da8baf21 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptMode.swift @@ -0,0 +1,14 @@ +/// How a coordinator-built script runs in the target browser surface, +/// mirroring the two legacy JavaScript primitives. +public enum ControlBrowserScriptMode: Sendable, Equatable { + /// The legacy `v2RunBrowserJavaScript` path: frame-selector aware, result + /// envelope (undefined detection), page-world with isolated-world retry. + /// + /// - Parameter useEval: The legacy `useEval` flag (`true` wraps the script + /// in `eval(...)`; `false` embeds it as an expression). + case frameAware(useEval: Bool) + /// The legacy direct `v2RunJavaScript(…, contentWorld: .page)` path used by + /// the console/error log readers, optionally bootstrapping the telemetry + /// hooks first (the legacy `v2BrowserEnsureTelemetryHooks`). + case pageWorld(installTelemetryHooks: Bool) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptOutcome.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptOutcome.swift new file mode 100644 index 000000000000..df3de777b042 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptOutcome.swift @@ -0,0 +1,8 @@ +/// The outcome of one JavaScript evaluation against a browser surface (the +/// `Sendable` twin of the controller-private `V2JavaScriptResult`). +public enum ControlBrowserScriptOutcome: Sendable, Equatable { + /// The script ran; its result, bridged. + case success(ControlBrowserScriptValue) + /// The script failed or timed out, with the legacy error message. + case failure(String) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptResolution.swift new file mode 100644 index 000000000000..cdb9e4d0accc --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptResolution.swift @@ -0,0 +1,21 @@ +/// The outcome of running a coordinator-built script against a browser +/// surface: the panel-resolution failure, or the resolved identity plus the +/// JavaScript outcome. +public enum ControlBrowserScriptResolution: Sendable, Equatable { + /// The browser surface did not resolve. + case failure(ControlBrowserPanelFailure) + /// The surface resolved and the script ran (or failed in JavaScript). + case resolved(identity: ControlBrowserPanelIdentity, outcome: Outcome) + + /// The JavaScript-level outcome of a resolved script run. + public enum Outcome: Sendable, Equatable { + /// The run failed (legacy `.failure(message)` → wire `js_error`). + case jsError(String) + /// The script evaluated to JavaScript `undefined` (the legacy + /// `V2BrowserUndefinedSentinel` result). + case undefined + /// The script produced a value, already passed through the legacy + /// `v2NormalizeJSValue` and bridged to a typed JSON value. + case value(JSONValue) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptValue.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptValue.swift new file mode 100644 index 000000000000..a4d7d2fc85a5 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserScriptValue.swift @@ -0,0 +1,19 @@ +/// A JavaScript evaluation result bridged into a `Sendable` value, preserving +/// the legacy distinction between JS `undefined` (the controller's +/// `V2BrowserUndefinedSentinel`) and every other value (already normalized by +/// the legacy `v2NormalizeJSValue` rules and bridged to ``JSONValue``). +public enum ControlBrowserScriptValue: Sendable, Equatable { + /// The script evaluated to JS `undefined` (the legacy sentinel case). + case undefined + /// The script evaluated to a value (JS `null` arrives as + /// ``JSONValue/null``). + case value(JSONValue) + + /// The legacy eval-envelope type key (`__cmux_t`), used when re-encoding + /// `undefined` into the wire payload exactly as `v2NormalizeJSValue` did. + public static let envelopeTypeKey = "__cmux_t" + /// The legacy eval-envelope value key (`__cmux_v`). + public static let envelopeValueKey = "__cmux_v" + /// The legacy eval-envelope `undefined` type tag. + public static let envelopeTypeUndefined = "undefined" +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateApplyResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateApplyResolution.swift new file mode 100644 index 000000000000..05377de1bcd0 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateApplyResolution.swift @@ -0,0 +1,8 @@ +/// The outcome of the app-side `browser.state.load` apply pass. +public enum ControlBrowserStateApplyResolution: Sendable, Equatable { + /// The browser surface did not resolve. + case failure(ControlBrowserPanelFailure) + /// The state applied (frame selector, navigation, cookies, storage — in + /// the legacy order, best-effort as legacy). + case applied(identity: ControlBrowserPanelIdentity) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCapture.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCapture.swift new file mode 100644 index 000000000000..a3f88ea73ee8 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCapture.swift @@ -0,0 +1,37 @@ +/// The app-captured pieces of a `browser.state.save`: everything the legacy +/// body read in one resolved-panel pass (so mid-pump state changes cannot +/// split the capture). +public struct ControlBrowserStateCapture: Sendable, Equatable { + /// The resolved workspace/surface identity. + public let identity: ControlBrowserPanelIdentity + /// The normalized local/session storage readout. + public let storage: JSONValue + /// The panel's cookies (empty when the store read timed out, as legacy). + public let cookies: [ControlBrowserCookie] + /// The panel's current URL (empty string when none, as legacy). + public let url: String + /// The surface's active frame selector, if any. + public let frameSelector: String? + + /// Creates a state capture. + /// + /// - Parameters: + /// - identity: The resolved identity. + /// - storage: The normalized storage readout. + /// - cookies: The panel's cookies. + /// - url: The panel's current URL. + /// - frameSelector: The surface's active frame selector, if any. + public init( + identity: ControlBrowserPanelIdentity, + storage: JSONValue, + cookies: [ControlBrowserCookie], + url: String, + frameSelector: String? + ) { + self.identity = identity + self.storage = storage + self.cookies = cookies + self.url = url + self.frameSelector = frameSelector + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCaptureResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCaptureResolution.swift new file mode 100644 index 000000000000..a2aae9f58406 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserStateCaptureResolution.swift @@ -0,0 +1,9 @@ +/// The outcome of the app-side `browser.state.save` capture pass. +public enum ControlBrowserStateCaptureResolution: Sendable, Equatable { + /// The browser surface did not resolve. + case failure(ControlBrowserPanelFailure) + /// The storage readout script failed (legacy `js_error`). + case jsError(String) + /// The state captured. + case captured(ControlBrowserStateCapture) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserSurfaceTarget.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserSurfaceTarget.swift new file mode 100644 index 000000000000..74c915e43b95 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserSurfaceTarget.swift @@ -0,0 +1,27 @@ +public import Foundation + +/// The pre-parsed target a `browser.*` command uses to resolve its browser +/// surface, mirroring the legacy `v2BrowserWithPanel` inputs: the routing +/// selectors pick the TabManager/Workspace, and the explicit surface/pane ids +/// (parsed by the coordinator from `surface_id`/`tab_id`/`pane_id`, accepting +/// `kind:N` refs) drive the legacy `v2ResolveBrowserSurfaceId` precedence. +public struct ControlBrowserSurfaceTarget: Sendable, Equatable { + /// The routing selectors for TabManager/Workspace resolution. + public let routing: ControlRoutingSelectors + /// The explicit surface target (`surface_id`, then `tab_id`), if any. + public let surfaceID: UUID? + /// The explicit `pane_id` target, if any. + public let paneID: UUID? + + /// Creates a browser surface target. + /// + /// - Parameters: + /// - routing: The routing selectors. + /// - surfaceID: The explicit surface target, if any. + /// - paneID: The explicit pane target, if any. + public init(routing: ControlRoutingSelectors, surfaceID: UUID?, paneID: UUID?) { + self.routing = routing + self.surfaceID = surfaceID + self.paneID = paneID + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabCloseResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabCloseResolution.swift new file mode 100644 index 000000000000..62482cc7e2e3 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabCloseResolution.swift @@ -0,0 +1,21 @@ +public import Foundation + +/// The outcome of the app-side `browser.tab.close`, preserving the legacy +/// body's distinct failures. +public enum ControlBrowserTabCloseResolution: Sendable, Equatable { + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// The workspace has no browser tabs (legacy `not_found` / + /// "No browser tabs"). + case noBrowserTabs + /// No matching browser tab (legacy `not_found` / "Browser tab not found"). + case tabNotFound + /// Closing would remove the last surface (legacy `invalid_state` / + /// "Cannot close the last surface"). + case lastSurface + /// The close failed (legacy `internal_error` / + /// "Failed to close browser tab", `data: {"surface_id": …}`). + case closeFailed(surfaceID: UUID) + /// The tab closed. + case closed(workspaceID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabListSnapshot.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabListSnapshot.swift new file mode 100644 index 000000000000..5917f1b68b4e --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabListSnapshot.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// The app snapshot behind `browser.tab.list`. +public struct ControlBrowserTabListSnapshot: Sendable, Equatable { + /// The resolved workspace id. + public let workspaceID: UUID + /// The workspace's focused panel id, if any (the payload's `surface_id`). + public let focusedSurfaceID: UUID? + /// The workspace's browser tabs, in panel order. + public let tabs: [ControlBrowserTabSummary] + + /// Creates a tab-list snapshot. + /// + /// - Parameters: + /// - workspaceID: The resolved workspace id. + /// - focusedSurfaceID: The focused panel id, if any. + /// - tabs: The browser tabs, in panel order. + public init(workspaceID: UUID, focusedSurfaceID: UUID?, tabs: [ControlBrowserTabSummary]) { + self.workspaceID = workspaceID + self.focusedSurfaceID = focusedSurfaceID + self.tabs = tabs + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabNewResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabNewResolution.swift new file mode 100644 index 000000000000..38c8aacf03d8 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabNewResolution.swift @@ -0,0 +1,14 @@ +public import Foundation + +/// The outcome of the app-side `browser.tab.new` creation. +public enum ControlBrowserTabNewResolution: Sendable, Equatable { + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// No target pane resolved (legacy `not_found` / "Target pane not found"). + case paneNotFound + /// Browser creation failed (legacy `internal_error` / + /// "Failed to create browser tab"). + case createFailed + /// The tab was created; `url` is the created panel's current URL. + case created(workspaceID: UUID, paneID: UUID, surfaceID: UUID, url: String) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSummary.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSummary.swift new file mode 100644 index 000000000000..2f86ad61e00e --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSummary.swift @@ -0,0 +1,32 @@ +public import Foundation + +/// A Sendable snapshot of one browser tab for `browser.tab.list`, in workspace +/// panel order (the coordinator derives the wire `index` from array position). +public struct ControlBrowserTabSummary: Sendable, Equatable { + /// The browser surface id. + public let surfaceID: UUID + /// The panel's display title. + public let title: String + /// The panel's current URL (empty string when none, as legacy). + public let url: String + /// Whether this surface is the workspace's focused panel. + public let isFocused: Bool + /// The containing pane id, if resolved. + public let paneID: UUID? + + /// Creates a tab summary. + /// + /// - Parameters: + /// - surfaceID: The browser surface id. + /// - title: The panel's display title. + /// - url: The panel's current URL. + /// - isFocused: Whether this surface is focused. + /// - paneID: The containing pane id, if resolved. + public init(surfaceID: UUID, title: String, url: String, isFocused: Bool, paneID: UUID?) { + self.surfaceID = surfaceID + self.title = title + self.url = url + self.isFocused = isFocused + self.paneID = paneID + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSwitchResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSwitchResolution.swift new file mode 100644 index 000000000000..6ac0cd25ae8c --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserTabSwitchResolution.swift @@ -0,0 +1,11 @@ +public import Foundation + +/// The outcome of the app-side `browser.tab.switch`. +public enum ControlBrowserTabSwitchResolution: Sendable, Equatable { + /// No workspace resolved (legacy `not_found` / "Workspace not found"). + case workspaceNotFound + /// No matching browser tab (legacy `not_found` / "Browser tab not found"). + case tabNotFound + /// The tab was focused. + case switched(workspaceID: UUID, surfaceID: UUID) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserURLResolution.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserURLResolution.swift new file mode 100644 index 000000000000..0d3a3ae8e7bb --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserURLResolution.swift @@ -0,0 +1,10 @@ +public import Foundation + +/// The outcome of `browser.url.get`. +public enum ControlBrowserURLResolution: Sendable, Equatable { + /// The workspace or browser panel did not resolve (legacy `not_found` / + /// "Surface not found or not a browser"). + case notFoundOrNotBrowser + /// The resolved panel's current URL (empty string when none, as legacy). + case ok(workspaceID: UUID, url: String) +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserZoomDirection.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserZoomDirection.swift new file mode 100644 index 000000000000..9d36b8f8ea32 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlBrowserZoomDirection.swift @@ -0,0 +1,9 @@ +/// The validated `browser.zoom.set` direction. +public enum ControlBrowserZoomDirection: Sendable, Equatable { + /// `"in"` → `zoomIn()`. + case zoomIn + /// `"out"` → `zoomOut()`. + case zoomOut + /// `"reset"` → `resetZoom()`. + case reset +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser.swift new file mode 100644 index 000000000000..b2d3cd3ec809 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser.swift @@ -0,0 +1,486 @@ +internal import Foundation + +/// The browser navigation/panel/tabs/network/state domain (`browser.*` minus +/// the DOM-element automation commands), lifted byte-faithfully from the +/// former `TerminalController.v2Browser*` bodies. Each payload is built +/// directly as a ``JSONValue``; the encoded wire bytes match. The coordinator +/// owns param parsing, error shaping, and ref minting; the irreducibly +/// app-coupled work (workspace/panel resolution, WKWebView JavaScript, cookie +/// stores) runs behind the ``ControlBrowserContext`` seam. +/// +/// This file carries the dispatch, the shared helpers, and the +/// open-split/navigation methods; the rest live in `+Browser2/3/4.swift` +/// (file-length budget). +extension ControlCommandCoordinator { + /// Runs one decoded request if it belongs to the browser domain, returning + /// the typed result; returns `nil` otherwise so the caller can fall + /// through. The integrator calls this from the core `handle`. The + /// worker-lane browser methods (`browser.download.wait`, + /// `browser.profiles.*`, `browser.import.cookies`) never reach the + /// main-actor dispatch, so they are not listed here. + /// + /// - Parameter request: The decoded request envelope. + /// - Returns: The command result, or `nil` if not a browser method owned here. + func handleBrowser(_ request: ControlRequest) -> ControlCallResult? { + switch request.method { + case "browser.open_split": + return browserOpenSplit(request.params) + case "browser.navigate": + return browserNavigate(request.params) + case "browser.back": + return browserNavSimple(request.params, action: .back) + case "browser.forward": + return browserNavSimple(request.params, action: .forward) + case "browser.reload": + return browserNavSimple(request.params, action: .reload) + case "browser.react_grab.toggle": + return browserReactGrabToggle(request.params) + case "browser.devtools.toggle": + return browserDevToolsToggle(request.params) + case "browser.console.show": + return browserConsoleShow(request.params) + case "browser.focus_mode.set": + return browserFocusModeSet(request.params) + case "browser.zoom.set": + return browserZoomSet(request.params) + case "browser.history.clear": + return browserHistoryClear(request.params) + case "browser.url.get": + return browserGetURL(request.params) + case "browser.focus_webview": + return browserFocusWebView(request.params) + case "browser.is_webview_focused": + return browserIsWebViewFocused(request.params) + case "browser.snapshot": + return browserSnapshot(request.params) + case "browser.import.dialog": + return browserImportDialog(request.params) + case "browser.cookies.get": + return browserCookiesGet(request.params) + case "browser.cookies.set": + return browserCookiesSet(request.params) + case "browser.cookies.clear": + return browserCookiesClear(request.params) + case "browser.storage.get": + return browserStorageGet(request.params) + case "browser.storage.set": + return browserStorageSet(request.params) + case "browser.storage.clear": + return browserStorageClear(request.params) + case "browser.tab.new": + return browserTabNew(request.params) + case "browser.tab.list": + return browserTabList(request.params) + case "browser.tab.switch": + return browserTabSwitch(request.params) + case "browser.tab.close": + return browserTabClose(request.params) + case "browser.console.list": + return browserConsoleList(request.params) + case "browser.console.clear": + return browserConsoleClear(request.params) + case "browser.errors.list": + return browserErrorsList(request.params) + case "browser.state.save": + return browserStateSave(request.params) + case "browser.state.load": + return browserStateLoad(request.params) + case "browser.viewport.set": + return browserNotSupported( + "browser.viewport.set", + details: "WKWebView does not provide a per-tab programmable viewport emulation API equivalent to CDP" + ) + case "browser.geolocation.set": + return browserNotSupported( + "browser.geolocation.set", + details: "WKWebView does not expose per-tab geolocation spoofing hooks equivalent to Playwright/CDP" + ) + case "browser.offline.set": + return browserNotSupported( + "browser.offline.set", + details: "WKWebView does not expose reliable per-tab offline emulation" + ) + case "browser.trace.start": + return browserNotSupported( + "browser.trace.start", + details: "Playwright trace artifacts are not available on WKWebView" + ) + case "browser.trace.stop": + return browserNotSupported( + "browser.trace.stop", + details: "Playwright trace artifacts are not available on WKWebView" + ) + case "browser.network.route": + return browserNetworkRoute(request.params) + case "browser.network.unroute": + return browserNetworkUnroute(request.params) + case "browser.network.requests": + return browserNetworkRequests(request.params) + case "browser.screencast.start": + return browserNotSupported( + "browser.screencast.start", + details: "WKWebView does not expose CDP screencast streaming" + ) + case "browser.screencast.stop": + return browserNotSupported( + "browser.screencast.stop", + details: "WKWebView does not expose CDP screencast streaming" + ) + case "browser.input_mouse": + return browserNotSupported( + "browser.input_mouse", + details: "Raw CDP mouse injection is unavailable; use browser.click/hover/scroll" + ) + case "browser.input_keyboard": + return browserNotSupported( + "browser.input_keyboard", + details: "Raw CDP keyboard injection is unavailable; use browser.press/keydown/keyup" + ) + case "browser.input_touch": + return browserNotSupported( + "browser.input_touch", + details: "Raw CDP touch injection is unavailable on WKWebView" + ) + default: + return nil + } + } + + // MARK: - Shared helpers + + /// Maps the shared browser-surface resolution failure ladder onto the + /// legacy `v2BrowserWithPanel` error results. + func browserPanelFailureResult(_ failure: ControlBrowserPanelFailure) -> ControlCallResult { + switch failure { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .paneNotFound(let paneID): + return .err( + code: "not_found", + message: "Pane not found", + data: .object(["pane_id": .string(paneID.uuidString)]) + ) + case .paneHasNoSelectedSurface(let paneID): + return .err( + code: "not_found", + message: "Pane has no selected surface", + data: .object(["pane_id": .string(paneID.uuidString)]) + ) + case .noFocusedBrowserSurface: + return .err(code: "not_found", message: "No focused browser surface", data: nil) + case .surfaceNotBrowser(let surfaceID): + return .err( + code: "invalid_params", + message: "Surface is not a browser", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + } + } + + /// The legacy `v2BrowserNotSupported` error shape. + func browserNotSupported(_ method: String, details: String) -> ControlCallResult { + .err( + code: "not_supported", + message: "\(method) is not supported on WKWebView", + data: .object(["details": .string(details)]) + ) + } + + /// The legacy `v2RejectUnresolvedHandles`: an error if any of the given + /// handle params is SUPPLIED but does not resolve (presence via the + /// non-null check so an empty explicit handle is not treated as absent). + func browserRejectUnresolvedHandles( + _ params: [String: JSONValue], + _ keys: [String] + ) -> ControlCallResult? { + for key in keys where hasNonNull(params, key) && uuid(params, key) == nil { + return .err(code: "invalid_params", message: "Unresolved \(key)", data: nil) + } + return nil + } + + /// Builds the shared browser-surface target from the request params + /// (`surface_id`/`tab_id` explicit surface, `pane_id`, plus routing). + func browserSurfaceTarget(_ params: [String: JSONValue]) -> ControlBrowserSurfaceTarget { + ControlBrowserSurfaceTarget( + routing: routingSelectors(params), + surfaceID: uuid(params, "surface_id") ?? uuid(params, "tab_id"), + paneID: uuid(params, "pane_id") + ) + } + + /// The legacy `v2JSONLiteral`: one JSON value as a JavaScript literal. + func browserJSONLiteral(_ value: JSONValue) -> String { + let object = value.foundationObject + if let data = try? JSONSerialization.data(withJSONObject: [object], options: []), + let text = String(data: data, encoding: .utf8), + text.count >= 2 { + return String(text.dropFirst().dropLast()) + } + if case .string(let s) = value { + return "\"\(s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\"" + } + return "null" + } + + /// The standard workspace/surface/window identity payload of a browser + /// action (the legacy `v2BrowserActionPayload`). + func browserActionPayload( + workspaceID: UUID, + surfaceID: UUID, + windowID: UUID?, + extra: [String: JSONValue] = [:] + ) -> JSONValue { + var payload: [String: JSONValue] = [ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ] + for (key, value) in extra { payload[key] = value } + return .object(payload) + } + + /// The workspace/surface identity prefix shared by the resolved-panel + /// payloads (`cookies.*`, `storage.*`, `console.*`, `state.*`, …). + func browserIdentityFields(_ identity: ControlBrowserPanelIdentity) -> [String: JSONValue] { + [ + "workspace_id": .string(identity.workspaceID.uuidString), + "workspace_ref": ref(.workspace, identity.workspaceID), + "surface_id": .string(identity.surfaceID.uuidString), + "surface_ref": ref(.surface, identity.surfaceID), + ] + } + + // MARK: - open_split + + /// `browser.open_split` — open a browser in a split (or externally per the + /// link-open rules / browser-disabled fallback). + func browserOpenSplit(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let urlStr = string(params, "url") + let respectExternalOpenRules = bool(params, "respect_external_open_rules") ?? false + + if context?.controlBrowserIsAvailabilityDisabled() == true { + if context?.controlBrowserIsDiffViewerURL(urlStr) == true { + return .err(code: "browser_disabled", message: "cmux browser is disabled", data: nil) + } + let outcome = context?.controlBrowserDisabledExternalOpen(rawURL: urlStr, routing: routing) ?? .noURL + return browserDisabledResult(outcome) + } + switch context?.controlBrowserRegisterDiffViewer( + urlString: urlStr, + token: string(params, "diff_viewer_token"), + files: params["diff_viewer_files"] + ) ?? .notApplicable { + case .notApplicable, .registered: + break + case .missingOrInvalidAllowlist: + return .err(code: "invalid_params", message: "Missing or invalid trusted diff viewer allowlist", data: nil) + case .invalidAllowlist: + return .err(code: "invalid_params", message: "Invalid trusted diff viewer allowlist", data: nil) + case .invalidAllowlistDetails(let details): + return .err( + code: "invalid_params", + message: "Invalid trusted diff viewer allowlist", + data: .object(["details": .string(details)]) + ) + } + + let inputs = ControlBrowserOpenSplitInputs( + urlString: urlStr, + respectExternalOpenRules: respectExternalOpenRules, + sourceSurfaceID: uuid(params, "surface_id"), + focusRequested: bool(params, "focus") ?? false, + showOmnibar: bool(params, "show_omnibar") ?? true, + transparentBackground: bool(params, "transparent_background") ?? false, + bypassRemoteProxy: bool(params, "bypass_remote_proxy") + ) + switch context?.controlBrowserOpenSplit(routing: routing, inputs: inputs) ?? .workspaceNotFound { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .externalOpenFailed(let url): + return .err( + code: "external_open_failed", + message: "Failed to open URL externally", + data: .object(["url": .string(url)]) + ) + case .openedExternally(let windowID, let workspaceID, let url): + return .ok(.object([ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .null, + "pane_ref": .null, + "surface_id": .null, + "surface_ref": .null, + "created_split": .bool(false), + "placement_strategy": .string("external"), + "opened_externally": .bool(true), + "url": .string(url), + ])) + case .noFocusedSurface: + return .err(code: "not_found", message: "No focused surface to split", data: nil) + case .sourceSurfaceNotFound(let surfaceID): + return .err( + code: "not_found", + message: "Source surface not found", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .createFailed: + return .err(code: "internal_error", message: "Failed to create browser", data: nil) + case .created(let snapshot): + return .ok(.object([ + "window_id": orNull(snapshot.windowID?.uuidString), + "window_ref": ref(.window, snapshot.windowID), + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "pane_id": orNull(snapshot.paneID?.uuidString), + "pane_ref": ref(.pane, snapshot.paneID), + "surface_id": .string(snapshot.surfaceID.uuidString), + "surface_ref": ref(.surface, snapshot.surfaceID), + "source_surface_id": .string(snapshot.sourceSurfaceID.uuidString), + "source_surface_ref": ref(.surface, snapshot.sourceSurfaceID), + "source_pane_id": orNull(snapshot.sourcePaneID?.uuidString), + "source_pane_ref": ref(.pane, snapshot.sourcePaneID), + "target_pane_id": orNull(snapshot.paneID?.uuidString), + "target_pane_ref": ref(.pane, snapshot.paneID), + "created_split": .bool(snapshot.createdSplit), + "placement_strategy": .string(snapshot.placementStrategy), + "show_omnibar": .bool(snapshot.showOmnibar), + "transparent_background": .bool(snapshot.transparentBackground), + "bypass_remote_proxy": .bool(snapshot.bypassRemoteProxy), + ])) + } + } + + // MARK: - navigate / history nav + + /// `browser.navigate` — smart-navigate a browser surface. + func browserNavigate(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + guard let url = string(params, "url") else { + return .err(code: "invalid_params", message: "Missing url", data: nil) + } + switch context?.controlBrowserNavigate(routing: routing, surfaceID: surfaceID, urlString: url) + ?? .notFoundOrNotBrowser { + case .notFoundOrNotBrowser: + return .err( + code: "not_found", + message: "Surface not found or not a browser", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .ok(let workspaceID, let windowID): + var payload: [String: JSONValue] = [ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ] + browserAppendPostSnapshot(params, surfaceID: surfaceID, payload: &payload) + return .ok(.object(payload)) + } + } + + /// `browser.back`/`forward`/`reload` — the legacy `v2BrowserNavSimple`. + func browserNavSimple( + _ params: [String: JSONValue], + action: ControlBrowserNavAction + ) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + switch context?.controlBrowserNavAction(routing: routing, surfaceID: surfaceID, action: action) + ?? .notFoundOrNotBrowser { + case .notFoundOrNotBrowser: + return .err( + code: "not_found", + message: "Surface not found or not a browser", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .ok(let workspaceID, let windowID): + var payload: [String: JSONValue] = [ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + ] + browserAppendPostSnapshot(params, surfaceID: surfaceID, payload: &payload) + return .ok(.object(payload)) + } + } + + /// The legacy `v2BrowserAppendPostSnapshot`: optionally appends a + /// post-action snapshot (built through the coordinator's own + /// `browserSnapshot`) to an action payload. + func browserAppendPostSnapshot( + _ params: [String: JSONValue], + surfaceID: UUID, + payload: inout [String: JSONValue] + ) { + guard bool(params, "snapshot_after") ?? false else { return } + + var snapshotParams: [String: JSONValue] = [ + "surface_id": .string(surfaceID.uuidString), + "interactive": .bool(bool(params, "snapshot_interactive") ?? true), + "cursor": .bool(bool(params, "snapshot_cursor") ?? false), + "compact": .bool(bool(params, "snapshot_compact") ?? true), + "max_depth": .int(Int64(max(0, int(params, "snapshot_max_depth") ?? 10))), + ] + if let selector = string(params, "snapshot_selector"), + !selector.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + snapshotParams["selector"] = .string(selector) + } + + switch browserSnapshot(snapshotParams) { + case .ok(let snapshotValue): + guard case .object(let snapshot) = snapshotValue else { + payload["post_action_snapshot_error"] = .object([ + "code": .string("internal_error"), + "message": .string("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(let code, let message, let data): + payload["post_action_snapshot_error"] = .object([ + "code": .string(code), + "message": .string(message), + "data": data ?? .null, + ]) + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser2.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser2.swift new file mode 100644 index 000000000000..6f0aa70d74fe --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser2.swift @@ -0,0 +1,296 @@ +internal import Foundation + +/// Browser domain, part 2: the focused-browser actions, history/url/web-view +/// focus reads, and the import dialog. See `+Browser.swift` for the dispatch. +extension ControlCommandCoordinator { + // MARK: - react grab + + /// `browser.react_grab.toggle` — toggle React Grab on the target browser. + func browserReactGrabToggle(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + if let error = browserRejectUnresolvedHandles(params, ["surface_id", "return_to", "workspace_id", "window_id"]) { + return error + } + switch context?.controlBrowserReactGrabToggle( + routing: routing, + browserSurfaceID: uuid(params, "surface_id"), + returnSurfaceID: uuid(params, "return_to") + ) ?? .notFound { + case .notFound: + return .err(code: "not_found", message: "No browser surface to toggle React Grab on", data: nil) + case .toggled(let workspaceID, let surfaceID, let windowID): + return .ok(browserActionPayload( + workspaceID: workspaceID, + surfaceID: surfaceID, + windowID: windowID, + extra: ["toggled": .bool(true)] + )) + } + } + + // MARK: - focused-browser actions + + /// `browser.devtools.toggle` — toggle the developer tools. + func browserDevToolsToggle(_ params: [String: JSONValue]) -> ControlCallResult { + browserFocusedAction(params) { context, routing, target in + context.controlBrowserDevToolsToggle(routing: routing, target: target) + } + } + + /// `browser.console.show` — show the developer-tools console. + func browserConsoleShow(_ params: [String: JSONValue]) -> ControlCallResult { + browserFocusedAction(params) { context, routing, target in + context.controlBrowserConsoleShow(routing: routing, target: target) + } + } + + /// `browser.focus_mode.set` — enter/exit/toggle browser focus mode. + func browserFocusModeSet(_ params: [String: JSONValue]) -> ControlCallResult { + let mode = (string(params, "mode") ?? "toggle").lowercased() + let enterAliases: Set = ["enter", "on", "true", "active"] + let exitAliases: Set = ["exit", "off", "false", "inactive"] + guard mode == "toggle" || enterAliases.contains(mode) || exitAliases.contains(mode) else { + return .err( + code: "invalid_params", + message: "mode must be one of: enter, exit, toggle, on, off", + data: nil + ) + } + let action: ControlBrowserFocusModeAction + if enterAliases.contains(mode) { + action = .activate + } else if exitAliases.contains(mode) { + action = .deactivate + } else { + action = .toggle + } + return browserFocusedAction(params, extra: ["mode": .string(mode)]) { context, routing, target in + context.controlBrowserFocusModeSet(routing: routing, target: target, action: action) + } + } + + /// `browser.zoom.set` — zoom in/out/reset. + func browserZoomSet(_ params: [String: JSONValue]) -> ControlCallResult { + let direction = (string(params, "direction") ?? "").lowercased() + guard ["in", "out", "reset"].contains(direction) else { + return .err( + code: "invalid_params", + message: "direction must be one of: in, out, reset", + data: nil + ) + } + let mapped: ControlBrowserZoomDirection + switch direction { + case "in": mapped = .zoomIn + case "out": mapped = .zoomOut + default: mapped = .reset + } + return browserFocusedAction(params, extra: ["direction": .string(direction)]) { context, routing, target in + context.controlBrowserZoomSet(routing: routing, target: target, direction: mapped) + } + } + + /// The shared guard/dispatch/payload shape of the focused-browser actions + /// (`devtools.toggle`, `console.show`, `focus_mode.set`, `zoom.set`): + /// TabManager guard, unresolved-handle rejection, the seam call, and the + /// `v2BrowserActionPayload` + `handled` payload. + private func browserFocusedAction( + _ params: [String: JSONValue], + extra: [String: JSONValue] = [:], + _ perform: ( + any ControlCommandContext, + ControlRoutingSelectors, + ControlBrowserFocusedActionTarget + ) -> ControlBrowserHandledResolution + ) -> ControlCallResult { + let routing = routingSelectors(params) + guard let context, context.controlSurfaceRoutingResolvesTabManager(routing: routing) else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + if let error = browserRejectUnresolvedHandles(params, ["surface_id", "workspace_id", "window_id"]) { + return error + } + let target = ControlBrowserFocusedActionTarget( + hasSurfaceParam: hasNonNull(params, "surface_id"), + surfaceID: uuid(params, "surface_id") + ) + switch perform(context, routing, target) { + case .notFound: + return .err(code: "not_found", message: "No browser surface found", data: nil) + case .acted(let workspaceID, let surfaceID, let windowID, let handled): + var fields = extra + fields["handled"] = .bool(handled) + return .ok(browserActionPayload( + workspaceID: workspaceID, + surfaceID: surfaceID, + windowID: windowID, + extra: fields + )) + } + } + + // MARK: - history / url / web-view focus + + /// `browser.history.clear` — clear the default profile's history (gated on + /// explicit `force=true`, as legacy). + func browserHistoryClear(_ params: [String: JSONValue]) -> ControlCallResult { + guard bool(params, "force") == true else { + return .err( + code: "invalid_params", + message: "browser.history.clear requires force=true", + data: nil + ) + } + context?.controlBrowserClearDefaultProfileHistory() + return .ok(.object([ + "cleared": .bool(true), + "scope": .string("default_profile"), + ])) + } + + /// `browser.url.get` — the surface's current URL (ids only, no refs, as + /// legacy). + func browserGetURL(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + switch context?.controlBrowserCurrentURL(routing: routing, surfaceID: surfaceID) + ?? .notFoundOrNotBrowser { + case .notFoundOrNotBrowser: + return .err( + code: "not_found", + message: "Surface not found or not a browser", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .ok(let workspaceID, let url): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "surface_id": .string(surfaceID.uuidString), + "url": .string(url), + ])) + } + } + + /// `browser.focus_webview` — move first responder into the web view. + func browserFocusWebView(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + switch context?.controlBrowserFocusWebView(routing: routing, surfaceID: surfaceID) + ?? .notFoundOrNotBrowser { + case .notFoundOrNotBrowser: + return .err( + code: "not_found", + message: "Surface not found or not a browser", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .webViewNotInWindow: + return .err(code: "invalid_state", message: "WebView is not in a window", data: nil) + case .webViewHidden: + return .err(code: "invalid_state", message: "WebView is hidden", data: nil) + case .focusDidNotMove: + return .err(code: "internal_error", message: "Focus did not move into web view", data: nil) + case .focused: + return .ok(.object(["focused": .bool(true)])) + } + } + + /// `browser.is_webview_focused` — whether first responder is inside the + /// web view (`false` when the surface does not resolve, as legacy). + func browserIsWebViewFocused(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let surfaceID = uuid(params, "surface_id") else { + return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil) + } + let focused = context?.controlBrowserIsWebViewFocused(routing: routing, surfaceID: surfaceID) ?? false + return .ok(.object(["focused": .bool(focused)])) + } + + // MARK: - import dialog + + /// `browser.import.dialog` — present the browser data import dialog. + func browserImportDialog(_ params: [String: JSONValue]) -> ControlCallResult { + let scope: ControlBrowserImportScope? + if params["scope"] != nil { + guard let raw = string(params, "scope")?.lowercased(), !raw.isEmpty else { + return .err( + code: "invalid_params", + message: "scope must be a non-empty string", + data: .object(["param": .string("scope")]) + ) + } + switch raw { + case "cookie", "cookies", "cookiesonly", "cookies_only", "cookies-only": + scope = .cookiesOnly + case "history", "historyonly", "history_only", "history-only": + scope = .historyOnly + case "cookiesandhistory", "cookies_and_history", "cookies-and-history", "all-basic": + scope = .cookiesAndHistory + case "everything", "all": + scope = .everything + default: + return .err( + code: "invalid_params", + message: "scope is invalid", + data: .object(["param": .string("scope")]) + ) + } + } else { + scope = nil + } + + let destinationProfileID: UUID? + if params["destination_profile"] != nil { + guard let query = string(params, "destination_profile"), !query.isEmpty else { + return .err( + code: "invalid_params", + message: "destination_profile must be a non-empty string", + data: .object(["param": .string("destination_profile")]) + ) + } + let createIfMissing = bool(params, "create_destination_profile") == true + || bool(params, "create_profile") == true + switch context?.controlBrowserImportResolveDestinationProfile( + query: query, + createIfMissing: createIfMissing + ) ?? .noMatch { + case .resolved(let profileID): + destinationProfileID = profileID + case .createFailed: + return .err( + code: "invalid_params", + message: "destination_profile could not be created", + data: .object(["param": .string("destination_profile")]) + ) + case .noMatch: + return .err( + code: "invalid_params", + message: "destination_profile does not match a cmux browser profile", + data: .object(["param": .string("destination_profile")]) + ) + } + } else { + destinationProfileID = nil + } + + context?.controlBrowserImportPresentDialog(scope: scope, destinationProfileID: destinationProfileID) + return .ok(.object([ + "opened": .bool(true), + "scope": scope.map { JSONValue.string($0.rawValue) } ?? .null, + ])) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser3.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser3.swift new file mode 100644 index 000000000000..08e7bf8f3d8d --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser3.swift @@ -0,0 +1,642 @@ +internal import Foundation + +/// Browser domain, part 3: the accessibility-style page snapshot and the +/// cookie/storage commands. See `+Browser.swift` for the dispatch. +extension ControlCommandCoordinator { + // MARK: - snapshot + + /// `browser.snapshot` — the role/name/ref page snapshot. The script and + /// shaping are byte-faithful to the legacy `v2BrowserSnapshot`; element + /// refs are minted through the seam into the shared app-side registry. + func browserSnapshot(_ params: [String: JSONValue]) -> ControlCallResult { + guard let context else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let interactiveOnly = bool(params, "interactive") ?? false + let includeCursor = bool(params, "cursor") ?? false + let compact = bool(params, "compact") ?? false + let maxDepth = max(0, int(params, "max_depth") ?? int(params, "maxDepth") ?? 12) + let scopeSelector = string(params, "selector") + + let interactiveLiteral = interactiveOnly ? "true" : "false" + let cursorLiteral = includeCursor ? "true" : "false" + let compactLiteral = compact ? "true" : "false" + let scopeLiteral = scopeSelector.map { browserJSONLiteral(.string($0)) } ?? "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 context.controlBrowserRunScript( + target: browserSurfaceTarget(params), + script: script, + timeout: 10.0, + mode: .frameAware(useEval: false) + ) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .resolved(let identity, let outcome): + switch outcome { + case .jsError(let message): + return .err(code: "js_error", message: message, data: nil) + case .undefined: + return .err(code: "js_error", message: "Invalid snapshot payload", data: nil) + case .value(let value): + guard case .object(let dict) = value else { + return .err(code: "js_error", message: "Invalid snapshot payload", data: nil) + } + return browserSnapshotPayload(dict: dict, identity: identity, context: context) + } + } + } + + /// Shapes the snapshot payload from the page readout (the second half of + /// the legacy `v2BrowserSnapshot` closure). + private func browserSnapshotPayload( + dict: [String: JSONValue], + identity: ControlBrowserPanelIdentity, + context: any ControlCommandContext + ) -> ControlCallResult { + let title = browserStringField(dict["title"]) ?? "" + let url = browserStringField(dict["url"]) ?? "" + let readyState = browserStringField(dict["ready_state"]) ?? "" + let text = browserStringField(dict["text"]) ?? "" + let html = browserStringField(dict["html"]) ?? "" + let entries = browserObjectArrayField(dict["entries"]) + + var refs: [String: JSONValue] = [:] + var treeLines: [String] = [] + var seenSelectors: Set = [] + + for entry in entries { + guard let selector = browserStringField(entry["selector"]), + !selector.isEmpty, + !seenSelectors.contains(selector) else { + continue + } + seenSelectors.insert(selector) + + let roleRaw = browserStringField(entry["role"]) ?? "generic" + let role = roleRaw.isEmpty ? "generic" : roleRaw + let name = (browserStringField(entry["name"]) ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + let depth = max(0, browserIntField(entry["depth"]) ?? 0) + + let refToken = context.controlBrowserAutomationState.allocateElementRef( + surfaceID: identity.surfaceID, + selector: selector + ) + let shortRef = refToken.hasPrefix("@") ? String(refToken.dropFirst()) : refToken + + var refInfo: [String: JSONValue] = ["role": .string(role)] + if !name.isEmpty { + refInfo["name"] = .string(name) + } + refs[shortRef] = .object(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 = browserIdentityFields(identity) + payload["snapshot"] = .string(snapshotText) + payload["title"] = .string(title) + payload["url"] = .string(url) + payload["ready_state"] = .string(readyState) + payload["page"] = .object([ + "title": .string(title), + "url": .string(url), + "ready_state": .string(readyState), + "text": .string(text), + "html": .string(html), + ]) + if !refs.isEmpty { + payload["refs"] = .object(refs) + } + return .ok(.object(payload)) + } + + /// `as? String` twin for a JSON field. + private func browserStringField(_ value: JSONValue?) -> String? { + guard case .string(let raw)? = value else { return nil } + return raw + } + + /// The legacy `as? Int` / `NSNumber.intValue` twin for a JSON field. + private func browserIntField(_ value: JSONValue?) -> Int? { + switch value { + case .int(let raw): + return Int(raw) + case .double(let raw): + return NSNumber(value: raw).intValue + default: + return nil + } + } + + /// The legacy `as? [[String: Any]] ?? []` twin: all elements must be + /// objects or the whole array is treated as absent. + private func browserObjectArrayField(_ value: JSONValue?) -> [[String: JSONValue]] { + guard case .array(let raw)? = value else { return [] } + var entries: [[String: JSONValue]] = [] + entries.reserveCapacity(raw.count) + for element in raw { + guard case .object(let entry) = element else { return [] } + entries.append(entry) + } + return entries + } + + /// The legacy `dict["ok"] as? Bool` twin (`Bool(exactly:)` semantics for + /// numbers, as `NSNumber as? Bool` behaves). + func browserOkFlag(_ value: JSONValue?) -> Bool { + switch value { + case .bool(let flag): + return flag + case .int(let raw): + return raw == 1 ? true : false + case .double(let raw): + return raw == 1.0 ? true : false + default: + return false + } + } + + // MARK: - cookies + + /// Serializes a cookie snapshot exactly as the legacy `v2BrowserCookieDict`. + func browserCookieObject(_ cookie: ControlBrowserCookie) -> JSONValue { + .object([ + "name": .string(cookie.name), + "value": .string(cookie.value), + "domain": .string(cookie.domain), + "path": .string(cookie.path), + "secure": .bool(cookie.isSecure), + "session_only": .bool(cookie.isSessionOnly), + "expires": cookie.expiresEpoch.map { JSONValue.int($0) } ?? .null, + ]) + } + + /// `browser.cookies.get` — read (and filter) the panel's cookies. + func browserCookiesGet(_ params: [String: JSONValue]) -> ControlCallResult { + switch context?.controlBrowserCookiesGet(target: browserSurfaceTarget(params)) + ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .timedOut: + return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + case .cookies(let identity, var cookies): + if let name = string(params, "name") { + cookies = cookies.filter { $0.name == name } + } + if let domain = string(params, "domain") { + cookies = cookies.filter { $0.domain.contains(domain) } + } + if let path = string(params, "path") { + cookies = cookies.filter { $0.path == path } + } + var payload = browserIdentityFields(identity) + payload["cookies"] = .array(cookies.map(browserCookieObject)) + return .ok(.object(payload)) + } + } + + /// `browser.cookies.set` — write cookie rows (the `cookies` array, or the + /// legacy single-cookie param shape). + func browserCookiesSet(_ params: [String: JSONValue]) -> ControlCallResult { + var rows: [JSONValue] = [] + if let arrayRows = browserCookieParamRows(params["cookies"]) { + rows = arrayRows + } else { + var single: [String: JSONValue] = [:] + if let name = string(params, "name") { single["name"] = .string(name) } + if let value = string(params, "value") { single["value"] = .string(value) } + if let url = string(params, "url") { single["url"] = .string(url) } + if let domain = string(params, "domain") { single["domain"] = .string(domain) } + if let path = string(params, "path") { single["path"] = .string(path) } + if let secure = bool(params, "secure") { single["secure"] = .bool(secure) } + if let expires = int(params, "expires") { single["expires"] = .int(Int64(expires)) } + if !single.isEmpty { + rows = [.object(single)] + } + } + + switch context?.controlBrowserCookiesSet(target: browserSurfaceTarget(params), rows: rows) + ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .emptyPayload: + return .err(code: "invalid_params", message: "Missing cookies payload", data: nil) + case .invalidCookie(let row): + return .err( + code: "invalid_params", + message: "Invalid cookie payload", + data: .object(["cookie": row]) + ) + case .timedOutSetting(let name): + return .err( + code: "timeout", + message: "Timed out setting cookie", + data: .object(["name": .string(name)]) + ) + case .set(let identity, let count): + var payload = browserIdentityFields(identity) + payload["set"] = .int(Int64(count)) + return .ok(.object(payload)) + } + } + + /// The legacy `params["cookies"] as? [[String: Any]]` twin: the array only + /// counts when every element is an object (else the single-cookie shape + /// applies). + private func browserCookieParamRows(_ value: JSONValue?) -> [JSONValue]? { + guard case .array(let raw)? = value else { return nil } + for element in raw { + guard case .object = element else { return nil } + } + return raw + } + + /// `browser.cookies.clear` — delete matching cookies. + func browserCookiesClear(_ params: [String: JSONValue]) -> ControlCallResult { + switch context?.controlBrowserCookiesClear( + target: browserSurfaceTarget(params), + name: string(params, "name"), + domain: string(params, "domain"), + hasAllParam: params["all"] != nil + ) ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .timedOut: + return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + case .cleared(let identity, let removed): + var payload = browserIdentityFields(identity) + payload["cleared"] = .int(Int64(removed)) + return .ok(.object(payload)) + } + } + + // MARK: - storage + + /// The legacy `v2BrowserStorageType` (`local` unless `session`). + func browserStorageType(_ params: [String: JSONValue]) -> String { + let type = (string(params, "storage") ?? string(params, "type") ?? "local").lowercased() + return (type == "session") ? "session" : "local" + } + + /// `browser.storage.get` — read one key or the whole store. + func browserStorageGet(_ params: [String: JSONValue]) -> ControlCallResult { + let storageType = browserStorageType(params) + let key = string(params, "key") + let typeLiteral = browserJSONLiteral(.string(storageType)) + let keyLiteral = key.map { browserJSONLiteral(.string($0)) } ?? "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 context?.controlBrowserRunScript( + target: browserSurfaceTarget(params), + script: script, + timeout: 5.0, + mode: .frameAware(useEval: true) + ) ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .resolved(let identity, let outcome): + switch outcome { + case .jsError(let message): + return .err(code: "js_error", message: message, data: nil) + case .undefined: + return .err( + code: "invalid_state", + message: "Storage unavailable", + data: .object(["type": .string(storageType)]) + ) + case .value(let value): + guard case .object(let dict) = value, browserOkFlag(dict["ok"]) else { + return .err( + code: "invalid_state", + message: "Storage unavailable", + data: .object(["type": .string(storageType)]) + ) + } + var payload = browserIdentityFields(identity) + payload["type"] = .string(storageType) + payload["key"] = orNull(key) + payload["value"] = dict["value"] ?? .null + return .ok(.object(payload)) + } + } + } + + /// `browser.storage.set` — write one key. + func browserStorageSet(_ params: [String: JSONValue]) -> ControlCallResult { + let storageType = browserStorageType(params) + guard let key = string(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) + } + let typeLiteral = browserJSONLiteral(.string(storageType)) + let keyLiteral = browserJSONLiteral(.string(key)) + let valueLiteral = browserJSONLiteral(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 context?.controlBrowserRunScript( + target: browserSurfaceTarget(params), + script: script, + timeout: 5.0, + mode: .frameAware(useEval: true) + ) ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .resolved(let identity, let outcome): + switch outcome { + case .jsError(let message): + return .err(code: "js_error", message: message, data: nil) + case .undefined: + return .err( + code: "invalid_state", + message: "Storage unavailable", + data: .object(["type": .string(storageType)]) + ) + case .value(let value): + guard case .object(let dict) = value, browserOkFlag(dict["ok"]) else { + return .err( + code: "invalid_state", + message: "Storage unavailable", + data: .object(["type": .string(storageType)]) + ) + } + var payload = browserIdentityFields(identity) + payload["type"] = .string(storageType) + payload["key"] = .string(key) + return .ok(.object(payload)) + } + } + } + + /// `browser.storage.clear` — clear the store. + func browserStorageClear(_ params: [String: JSONValue]) -> ControlCallResult { + let storageType = browserStorageType(params) + let typeLiteral = browserJSONLiteral(.string(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 context?.controlBrowserRunScript( + target: browserSurfaceTarget(params), + script: script, + timeout: 5.0, + mode: .frameAware(useEval: true) + ) ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .resolved(let identity, let outcome): + switch outcome { + case .jsError(let message): + return .err(code: "js_error", message: message, data: nil) + case .undefined: + return .err( + code: "invalid_state", + message: "Storage unavailable", + data: .object(["type": .string(storageType)]) + ) + case .value(let value): + guard case .object(let dict) = value, browserOkFlag(dict["ok"]) else { + return .err( + code: "invalid_state", + message: "Storage unavailable", + data: .object(["type": .string(storageType)]) + ) + } + var payload = browserIdentityFields(identity) + payload["type"] = .string(storageType) + payload["cleared"] = .bool(true) + return .ok(.object(payload)) + } + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser4.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser4.swift new file mode 100644 index 000000000000..58bf98c7f901 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+Browser4.swift @@ -0,0 +1,417 @@ +internal import Foundation + +/// Browser domain, part 4: tabs, console/error logs, state save/load, and the +/// unsupported-network commands. See `+Browser.swift` for the dispatch. +extension ControlCommandCoordinator { + // MARK: - tabs + + /// `browser.tab.list` — the workspace's browser tabs. + func browserTabList(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + guard let snapshot = context?.controlBrowserTabList(routing: routing) else { + return .err(code: "not_found", message: "Workspace not found", data: nil) + } + let tabs: [JSONValue] = snapshot.tabs.enumerated().map { index, tab in + .object([ + "id": .string(tab.surfaceID.uuidString), + "ref": ref(.surface, tab.surfaceID), + "index": .int(Int64(index)), + "title": .string(tab.title), + "url": .string(tab.url), + "focused": .bool(tab.isFocused), + "pane_id": orNull(tab.paneID?.uuidString), + "pane_ref": ref(.pane, tab.paneID), + ]) + } + return .ok(.object([ + "workspace_id": .string(snapshot.workspaceID.uuidString), + "workspace_ref": ref(.workspace, snapshot.workspaceID), + "surface_id": orNull(snapshot.focusedSurfaceID?.uuidString), + "surface_ref": ref(.surface, snapshot.focusedSurfaceID), + "tabs": .array(tabs), + ])) + } + + /// `browser.tab.new` — create a browser tab in the resolved pane. + func browserTabNew(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + let urlStr = string(params, "url") + guard context?.controlBrowserIsAvailabilityEnabled() == true else { + let outcome = context?.controlBrowserDisabledExternalOpen(rawURL: urlStr, routing: routing) ?? .noURL + return browserDisabledResult(outcome) + } + switch context?.controlBrowserTabNew( + routing: routing, + urlString: urlStr, + explicitPaneID: uuid(params, "pane_id") ?? uuid(params, "target_pane_id"), + paneFromSurfaceID: uuid(params, "surface_id") + ) ?? .workspaceNotFound { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .paneNotFound: + return .err(code: "not_found", message: "Target pane not found", data: nil) + case .createFailed: + return .err(code: "internal_error", message: "Failed to create browser tab", data: nil) + case .created(let workspaceID, let paneID, let surfaceID, let url): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "pane_id": .string(paneID.uuidString), + "pane_ref": ref(.pane, paneID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + "url": .string(url), + ])) + } + } + + /// `browser.tab.switch` — focus a browser tab. + func browserTabSwitch(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + switch context?.controlBrowserTabSwitch( + routing: routing, + explicitID: uuid(params, "target_surface_id") ?? uuid(params, "tab_id"), + index: int(params, "index"), + surfaceID: uuid(params, "surface_id") + ) ?? .workspaceNotFound { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .tabNotFound: + return .err(code: "not_found", message: "Browser tab not found", data: nil) + case .switched(let workspaceID, let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + ])) + } + } + + /// `browser.tab.close` — close a browser tab. + func browserTabClose(_ params: [String: JSONValue]) -> ControlCallResult { + let routing = routingSelectors(params) + guard context?.controlSurfaceRoutingResolvesTabManager(routing: routing) == true else { + return .err(code: "unavailable", message: "TabManager not available", data: nil) + } + switch context?.controlBrowserTabClose( + routing: routing, + explicitID: uuid(params, "target_surface_id") ?? uuid(params, "tab_id"), + index: int(params, "index"), + surfaceID: uuid(params, "surface_id") + ) ?? .workspaceNotFound { + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .noBrowserTabs: + return .err(code: "not_found", message: "No browser tabs", data: nil) + case .tabNotFound: + return .err(code: "not_found", message: "Browser tab not found", data: nil) + case .lastSurface: + return .err(code: "invalid_state", message: "Cannot close the last surface", data: nil) + case .closeFailed(let surfaceID): + return .err( + code: "internal_error", + message: "Failed to close browser tab", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .closed(let workspaceID, let surfaceID): + return .ok(.object([ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + ])) + } + } + + // MARK: - console / error logs + + /// `browser.console.list` — drain (optionally clearing) the page console log. + func browserConsoleList(_ params: [String: JSONValue]) -> ControlCallResult { + browserTelemetryLogList( + params, + logExpression: "window.__cmuxConsoleLog", + clearAssignment: "window.__cmuxConsoleLog = [];", + itemsKey: "entries" + ) + } + + /// `browser.console.clear` — `console.list` with `clear=true` (the legacy + /// param-forwarding shape). + func browserConsoleClear(_ params: [String: JSONValue]) -> ControlCallResult { + var withClear = params + withClear["clear"] = .bool(true) + return browserConsoleList(withClear) + } + + /// `browser.errors.list` — drain (optionally clearing) the page error log. + func browserErrorsList(_ params: [String: JSONValue]) -> ControlCallResult { + browserTelemetryLogList( + params, + logExpression: "window.__cmuxErrorLog", + clearAssignment: "window.__cmuxErrorLog = [];", + itemsKey: "errors" + ) + } + + /// The shared console/error log reader (the two legacy bodies differed + /// only in the log global and the payload key). + private func browserTelemetryLogList( + _ params: [String: JSONValue], + logExpression: String, + clearAssignment: String, + itemsKey: String + ) -> ControlCallResult { + let clear = bool(params, "clear") ?? false + let clearLiteral = clear ? "true" : "false" + let script = """ + (() => { + const items = Array.isArray(\(logExpression)) ? \(logExpression).slice() : []; + if (\(clearLiteral)) { + \(clearAssignment) + } + return { ok: true, items }; + })() + """ + switch context?.controlBrowserRunScript( + target: browserSurfaceTarget(params), + script: script, + timeout: 5.0, + mode: .pageWorld(installTelemetryHooks: true) + ) ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .resolved(let identity, let outcome): + switch outcome { + case .jsError(let message): + return .err(code: "js_error", message: message, data: nil) + case .undefined: + var payload = browserIdentityFields(identity) + payload[itemsKey] = .array([]) + payload["count"] = .int(0) + return .ok(.object(payload)) + case .value(let value): + var items: [JSONValue] = [] + if case .object(let dict) = value, case .array(let raw)? = dict["items"] { + items = raw + } + var payload = browserIdentityFields(identity) + payload[itemsKey] = .array(items) + payload["count"] = .int(Int64(items.count)) + return .ok(.object(payload)) + } + } + } + + // MARK: - state save / load + + /// `browser.state.save` — capture URL/cookies/storage/frame selector and + /// write the state file (the file write happens here; the capture is one + /// resolved-panel pass app-side). + func browserStateSave(_ params: [String: JSONValue]) -> ControlCallResult { + guard let path = string(params, "path") else { + return .err(code: "invalid_params", message: "Missing path", data: nil) + } + 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) + }; + })() + """ + switch context?.controlBrowserStateCapture( + target: browserSurfaceTarget(params), + storageScript: storageScript + ) ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .jsError(let message): + return .err(code: "js_error", message: message, data: nil) + case .captured(let capture): + let cookieObjects = capture.cookies.map(browserCookieObject) + let state: JSONValue = .object([ + "url": .string(capture.url), + "cookies": .array(cookieObjects), + "storage": capture.storage, + "frame_selector": orNull(capture.frameSelector), + ]) + do { + let data = try JSONSerialization.data( + withJSONObject: state.foundationObject, + 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: .object([ + "path": .string(path), + "error": .string(error.localizedDescription), + ]) + ) + } + var payload = browserIdentityFields(capture.identity) + payload["path"] = .string(path) + payload["cookies"] = .int(Int64(cookieObjects.count)) + return .ok(.object(payload)) + } + } + + /// `browser.state.load` — read the state file (here) and apply it in one + /// resolved-panel pass app-side. + func browserStateLoad(_ params: [String: JSONValue]) -> ControlCallResult { + guard let path = string(params, "path") else { + return .err(code: "invalid_params", message: "Missing path", data: nil) + } + let fileURL = URL(fileURLWithPath: path) + let raw: [String: Any] + do { + let data = try Data(contentsOf: fileURL) + guard let object = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return .err( + code: "invalid_params", + message: "State file must contain a JSON object", + data: .object(["path": .string(path)]) + ) + } + raw = object + } catch { + return .err( + code: "not_found", + message: "Failed to read state file", + data: .object([ + "path": .string(path), + "error": .string(error.localizedDescription), + ]) + ) + } + + let frameSelector: String? + if let selector = raw["frame_selector"] as? String, !selector.isEmpty { + frameSelector = selector + } else { + frameSelector = nil + } + + let navigateTo: String? + if let urlStr = raw["url"] as? String, !urlStr.isEmpty { + navigateTo = urlStr + } else { + navigateTo = nil + } + + var cookieRows: [JSONValue] = [] + if let rows = raw["cookies"] as? [[String: Any]] { + cookieRows = rows.compactMap { JSONValue(foundationObject: $0) } + } + + let storageScript: String? + if let storage = raw["storage"] as? [String: Any], + let storageValue = JSONValue(foundationObject: storage) { + let storageLiteral = browserJSONLiteral(storageValue) + storageScript = """ + (() => { + 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; + })() + """ + } else { + storageScript = nil + } + + switch context?.controlBrowserStateApply( + target: browserSurfaceTarget(params), + frameSelector: frameSelector, + navigateToURLString: navigateTo, + cookieRows: cookieRows, + storageScript: storageScript + ) ?? .failure(.tabManagerUnavailable) { + case .failure(let failure): + return browserPanelFailureResult(failure) + case .applied(let identity): + var payload = browserIdentityFields(identity) + payload["path"] = .string(path) + payload["loaded"] = .bool(true) + return .ok(.object(payload)) + } + } + + // MARK: - unsupported network commands + + /// `browser.network.route` — record the attempt, then `not_supported`. + func browserNetworkRoute(_ params: [String: JSONValue]) -> ControlCallResult { + if let surfaceID = uuid(params, "surface_id") { + context?.controlBrowserRecordUnsupportedRequest( + surfaceID: surfaceID, + request: .object(["action": .string("route"), "params": .object(params)]) + ) + } + return browserNotSupported( + "browser.network.route", + details: "WKWebView does not provide CDP-style request interception/mocking" + ) + } + + /// `browser.network.unroute` — record the attempt, then `not_supported`. + func browserNetworkUnroute(_ params: [String: JSONValue]) -> ControlCallResult { + if let surfaceID = uuid(params, "surface_id") { + context?.controlBrowserRecordUnsupportedRequest( + surfaceID: surfaceID, + request: .object(["action": .string("unroute"), "params": .object(params)]) + ) + } + return browserNotSupported( + "browser.network.unroute", + details: "WKWebView does not provide CDP-style request interception/mocking" + ) + } + + /// `browser.network.requests` — `not_supported`, carrying the recorded + /// attempts when a surface is given. + func browserNetworkRequests(_ params: [String: JSONValue]) -> ControlCallResult { + if let surfaceID = uuid(params, "surface_id") { + let items = context?.controlBrowserUnsupportedRequests(surfaceID: surfaceID) ?? [] + return .err( + code: "not_supported", + message: "browser.network.requests is not supported on WKWebView", + data: .object([ + "details": .string("Request interception logs are unavailable without CDP network hooks"), + "recorded_requests": .array(items), + ]) + ) + } + return browserNotSupported( + "browser.network.requests", + details: "Request interception logs are unavailable without CDP network hooks" + ) + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomation.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomation.swift new file mode 100644 index 000000000000..96a41b39b060 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomation.swift @@ -0,0 +1,595 @@ +internal import Foundation + +/// The browser DOM-automation domain (element refs, JS eval, dialogs, +/// frames), lifted byte-faithfully from the former `TerminalController` +/// `v2Browser*` bodies. Script strings, retry/wait loops, payload shapes, and +/// every error code/message are identical; only the WKWebView reach (JS +/// execution, snapshot capture, user-script injection, dialog completion +/// handlers) crosses the ``ControlBrowserAutomationContext`` seam. +extension ControlCommandCoordinator { + /// Dispatches the browser DOM-automation methods this coordinator owns; + /// returns `nil` for anything else (including the nav/tab/network browser + /// methods) so the core `handle(_:)` can fall through. + func handleBrowserAutomation(_ request: ControlRequest) -> ControlCallResult? { + let params = request.params + switch request.method { + case "browser.eval": + return browserEval(params) + case "browser.wait": + return browserWait(params) + case "browser.click": + return browserClick(params) + case "browser.dblclick": + return browserDblClick(params) + case "browser.hover": + return browserHover(params) + case "browser.focus": + return browserFocusElement(params) + case "browser.type": + return browserType(params) + case "browser.fill": + return browserFill(params) + case "browser.press": + return browserPress(params) + case "browser.keydown": + return browserKeyDown(params) + case "browser.keyup": + return browserKeyUp(params) + case "browser.check": + return browserCheck(params, checked: true) + case "browser.uncheck": + return browserCheck(params, checked: false) + case "browser.select": + return browserSelect(params) + case "browser.scroll": + return browserScroll(params) + case "browser.scroll_into_view": + return browserScrollIntoView(params) + case "browser.screenshot": + return browserScreenshot(params) + case "browser.get.text": + return browserGetText(params) + case "browser.get.html": + return browserGetHTML(params) + case "browser.get.value": + return browserGetValue(params) + case "browser.get.attr": + return browserGetAttr(params) + case "browser.get.title": + return browserGetTitle(params) + case "browser.get.count": + return browserGetCount(params) + case "browser.get.box": + return browserGetBox(params) + case "browser.get.styles": + return browserGetStyles(params) + case "browser.is.visible": + return browserIsVisible(params) + case "browser.is.enabled": + return browserIsEnabled(params) + case "browser.is.checked": + return browserIsChecked(params) + case "browser.find.role": + return browserFindRole(params) + case "browser.find.text": + return browserFindText(params) + case "browser.find.label": + return browserFindLabel(params) + case "browser.find.placeholder": + return browserFindPlaceholder(params) + case "browser.find.alt": + return browserFindAlt(params) + case "browser.find.title": + return browserFindTitle(params) + case "browser.find.testid": + return browserFindTestId(params) + case "browser.find.first": + return browserFindFirst(params) + case "browser.find.last": + return browserFindLast(params) + case "browser.find.nth": + return browserFindNth(params) + case "browser.frame.select": + return browserFrameSelect(params) + case "browser.frame.main": + return browserFrameMain(params) + case "browser.dialog.accept": + return browserDialogRespond(params, accept: true) + case "browser.dialog.dismiss": + return browserDialogRespond(params, accept: false) + case "browser.highlight": + return browserHighlight(params) + case "browser.addinitscript": + return browserAddInitScript(params) + case "browser.addscript": + return browserAddScript(params) + case "browser.addstyle": + return browserAddStyle(params) + default: + return nil + } + } + + /// The browser-automation view of the seam. Once the integrator adds + /// ``ControlBrowserAutomationContext`` to the ``ControlCommandContext`` + /// umbrella this cast is statically guaranteed (and may be simplified to + /// `context`); until then it lets the domain build standalone without + /// touching the integrator-owned umbrella file. + var browserContext: (any ControlBrowserAutomationContext)? { + context as? any ControlBrowserAutomationContext + } + + // MARK: - Panel resolution (twin of v2BrowserWithPanel) + + /// Resolves the target browser panel and runs `body` against it, + /// translating each ``ControlBrowserPanelResolution`` failure into the + /// exact legacy error. + func withBrowserPanel( + _ params: [String: JSONValue], + _ body: (_ workspaceID: UUID, _ surfaceID: UUID) -> ControlCallResult + ) -> ControlCallResult { + let resolution = browserContext?.controlBrowserResolvePanel( + routing: routingSelectors(params), + surfaceID: uuid(params, "surface_id") ?? uuid(params, "tab_id") + ) ?? .tabManagerUnavailable + guard case .resolved(let workspaceID, let surfaceID) = resolution else { + return browserPanelResolutionError(resolution) + } + return body(workspaceID, surfaceID) + } + + /// The legacy error for each non-resolved panel-resolution outcome. + func browserPanelResolutionError(_ resolution: ControlBrowserPanelResolution) -> ControlCallResult { + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: "TabManager not available", data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: "Workspace not found", data: nil) + case .paneNotFound(let paneID): + return .err( + code: "not_found", + message: "Pane not found", + data: .object(["pane_id": .string(paneID.uuidString)]) + ) + case .paneHasNoSelectedSurface(let paneID): + return .err( + code: "not_found", + message: "Pane has no selected surface", + data: .object(["pane_id": .string(paneID.uuidString)]) + ) + case .noFocusedBrowserSurface: + return .err(code: "not_found", message: "No focused browser surface", data: nil) + case .surfaceNotBrowser(let surfaceID): + return .err( + code: "invalid_params", + message: "Surface is not a browser", + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .resolved: + return .err(code: "internal_error", message: "Browser operation failed", data: nil) + } + } + + // MARK: - Script plumbing + + /// Runs a frame-scoped automation script through the seam (the legacy + /// `v2RunBrowserJavaScript` call shape, defaults included). + func browserRunScript( + surfaceID: UUID, + script: String, + timeout: TimeInterval = 5.0, + useEval: Bool = true + ) -> ControlBrowserScriptOutcome { + browserContext?.controlBrowserRunAutomationScript( + surfaceID: surfaceID, + script: script, + timeout: timeout, + useEval: useEval + ) ?? .failure("Browser operation failed") + } + + /// The selector param accepted by element commands (was + /// `v2BrowserSelector`): `selector` / `sel` / `element_ref` / `ref`. + func browserSelectorParam(_ params: [String: JSONValue]) -> String? { + string(params, "selector") + ?? string(params, "sel") + ?? string(params, "element_ref") + ?? string(params, "ref") + } + + /// A string embedded as a JSON literal for interpolation into a script + /// (was `v2JSONLiteral`, whose call sites in this domain all pass + /// strings). + func browserJSONLiteral(_ value: String) -> 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()) + } + return "\"\(value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\""))\"" + } + + // MARK: - Bridged-result casts (legacy `as?` semantics on JSONValue) + + /// The object payload of a bridged script value (the legacy + /// `value as? [String: Any]`). + func browserScriptObject(_ value: ControlBrowserScriptValue) -> [String: JSONValue]? { + guard case .value(.object(let dict)) = value else { return nil } + return dict + } + + /// The legacy `NSNumber as? Bool` bridge: `true`/`false` for booleans and + /// for numbers exactly equal to 1/0; otherwise `nil`. + func browserExactBool(_ value: JSONValue?) -> Bool? { + switch value { + case .bool(let flag): + return flag + case .int(let number): + if number == 0 { return false } + if number == 1 { return true } + return nil + case .double(let number): + if number == 0 { return false } + if number == 1 { return true } + return nil + default: + return nil + } + } + + /// The legacy `(value as? NSNumber)?.intValue` bridge (booleans count as + /// 1/0, doubles truncate toward zero via `NSNumber.intValue`). + func browserNumberInt(_ value: JSONValue?) -> Int? { + switch value { + case .int(let number): + return Int(number) + case .double(let number): + return NSNumber(value: number).intValue + case .bool(let flag): + return NSNumber(value: flag).intValue + default: + return nil + } + } + + /// The legacy `value as? String` bridge. + func browserStringValue(_ value: JSONValue?) -> String? { + guard case .string(let text)? = value else { return nil } + return text + } + + /// The wire payload for a bridged script value (the legacy + /// `v2NormalizeJSValue` result): `undefined` re-encodes as the eval + /// envelope, exactly as before. + func browserPayloadValue(_ value: ControlBrowserScriptValue) -> JSONValue { + switch value { + case .undefined: + return .object([ + ControlBrowserScriptValue.envelopeTypeKey: + .string(ControlBrowserScriptValue.envelopeTypeUndefined), + ControlBrowserScriptValue.envelopeValueKey: .null, + ]) + case .value(let jsonValue): + return jsonValue + } + } + + // MARK: - Wait plumbing (twin of v2WaitForBrowserCondition) + + /// Polls a JS condition with mutation observers and navigation listeners + /// until it holds or the timeout fires (script byte-identical to the + /// legacy `v2WaitForBrowserCondition`). + func browserWaitForCondition( + surfaceID: UUID, + conditionScript: String, + timeoutMs: Int + ) -> Bool { + let timeout = Double(timeoutMs) / 1000.0 + let waitScript = """ + (() => { + const __cmuxEvaluate = () => { + try { + return !!(\(conditionScript)); + } catch (_) { + return false; + } + }; + + if (__cmuxEvaluate()) { + return true; + } + + return new Promise((resolve) => { + let finished = false; + let observer = null; + const cleanups = []; + const finish = (value) => { + if (finished) return; + finished = true; + if (observer) observer.disconnect(); + for (const cleanup of cleanups) { + try { cleanup(); } catch (_) {} + } + resolve(value); + }; + const recheck = () => { + if (__cmuxEvaluate()) { + finish(true); + } + }; + const addListener = (target, eventName, options) => { + if (!target || typeof target.addEventListener !== 'function') return; + const handler = () => recheck(); + target.addEventListener(eventName, handler, options); + cleanups.push(() => target.removeEventListener(eventName, handler, options)); + }; + + try { + observer = new MutationObserver(() => recheck()); + observer.observe(document.documentElement || document, { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } catch (_) {} + + addListener(document, 'readystatechange', true); + addListener(window, 'load', true); + addListener(window, 'pageshow', true); + addListener(window, 'hashchange', true); + addListener(window, 'popstate', true); + + const timeoutId = window.setTimeout(() => { + finish(false); + }, \(timeoutMs)); + cleanups.push(() => window.clearTimeout(timeoutId)); + recheck(); + }); + })() + """ + + switch browserRunScript( + surfaceID: surfaceID, + script: waitScript, + timeout: timeout + 1.0, + useEval: false + ) { + case .success(let value): + guard case .value(let jsonValue) = value else { return false } + return browserExactBool(jsonValue) == true + case .failure: + return false + } + } + + // MARK: - Not-found diagnostics (twins of v2BrowserNotFoundDiagnostics / v2BrowserElementNotFoundResult) + + /// Collects selector diagnostics for an element-not-found error (script + /// and output keys byte-identical to `v2BrowserNotFoundDiagnostics`). + func browserNotFoundDiagnostics( + surfaceID: UUID, + selector: String + ) -> [String: JSONValue] { + let selectorLiteral = browserJSONLiteral(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 browserRunScript(surfaceID: surfaceID, script: script, timeout: 4.0) { + case .failure(let message): + return [ + "selector": .string(selector), + "diagnostics_error": .string(message), + ] + case .success(let value): + guard let dict = browserScriptObject(value) else { + return ["selector": .string(selector)] + } + var out: [String: JSONValue] = ["selector": .string(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"] = 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 + } + } + + /// Builds the legacy element-not-found error (message selection and data + /// keys identical to `v2BrowserElementNotFoundResult`). + func browserElementNotFoundResult( + actionName: String, + selector: String, + attempts: Int, + surfaceID: UUID + ) -> ControlCallResult { + var data = browserNotFoundDiagnostics(surfaceID: surfaceID, selector: selector) + data["action"] = .string(actionName) + data["retry_attempts"] = .int(Int64(attempts)) + data["hint"] = .string("Run 'browser snapshot' to refresh refs, then retry with a more specific selector.") + + let count = browserNumberInt(data["match_count"]) ?? 0 + let visibleCount = browserNumberInt(data["visible_match_count"]) ?? 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: .object(data)) + } + + // MARK: - Selector action loop (twin of v2BrowserSelectorAction) + + /// Resolves the selector, runs the action script with the legacy retry + + /// appear-wait loop, and shapes the standard action payload. + func browserSelectorAction( + _ params: [String: JSONValue], + actionName: String, + scriptBuilder: (_ selectorLiteral: String) -> String + ) -> ControlCallResult { + guard let selectorRaw = browserSelectorParam(params) else { + return .err(code: "invalid_params", message: "Missing selector", data: nil) + } + + return withBrowserPanel(params) { workspaceID, surfaceID in + guard let selector = browserContext?.controlBrowserAutomationState + .resolveSelector(selectorRaw, surfaceID: surfaceID) else { + return .err( + code: "not_found", + message: "Element reference not found", + data: .object(["selector": .string(selectorRaw)]) + ) + } + let script = scriptBuilder(browserJSONLiteral(selector)) + let retryAttempts = max(1, int(params, "retry_attempts") ?? 3) + let selectorCondition = "document.querySelector(\(browserJSONLiteral(selector))) !== null" + + for attempt in 1...retryAttempts { + switch browserRunScript(surfaceID: surfaceID, script: script, useEval: false) { + case .failure(let message): + return .err( + code: "js_error", + message: message, + data: .object(["action": .string(actionName), "selector": .string(selector)]) + ) + case .success(let value): + if let dict = browserScriptObject(value), + browserExactBool(dict["ok"]) == true { + var payload: [String: JSONValue] = [ + "workspace_id": .string(workspaceID.uuidString), + "surface_id": .string(surfaceID.uuidString), + "action": .string(actionName), + "attempts": .int(Int64(attempt)), + ] + payload["workspace_ref"] = ref(.workspace, workspaceID) + payload["surface_ref"] = ref(.surface, surfaceID) + if let resultValue = dict["value"] { + payload["value"] = resultValue + } + browserAppendPostSnapshot(params, surfaceID: surfaceID, payload: &payload) + return .ok(.object(payload)) + } + + let errorText = browserScriptObject(value).flatMap { browserStringValue($0["error"]) } + if errorText == "not_found", attempt < retryAttempts { + let waitTimeoutMs = max(80, (retryAttempts - attempt) * 80) + guard browserWaitForCondition( + surfaceID: surfaceID, + conditionScript: selectorCondition, + timeoutMs: waitTimeoutMs + ) else { + return browserElementNotFoundResult( + actionName: actionName, + selector: selector, + attempts: attempt, + surfaceID: surfaceID + ) + } + continue + } + if errorText == "not_found" { + return browserElementNotFoundResult( + actionName: actionName, + selector: selector, + attempts: retryAttempts, + surfaceID: surfaceID + ) + } + + return .err( + code: "js_error", + message: "Browser action failed", + data: .object(["action": .string(actionName), "selector": .string(selector)]) + ) + } + } + + return browserElementNotFoundResult( + actionName: actionName, + selector: selector, + attempts: retryAttempts, + surfaceID: surfaceID + ) + } + } + + /// The standard workspace/surface identity payload most browser bodies + /// open with. + func browserIdentityPayload(workspaceID: UUID, surfaceID: UUID) -> [String: JSONValue] { + [ + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + ] + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationActions.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationActions.swift new file mode 100644 index 000000000000..a6d22d00c208 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationActions.swift @@ -0,0 +1,645 @@ +internal import Foundation + +/// The browser DOM-automation action bodies (`browser.eval`/`wait`/`click`/…/ +/// `screenshot`/`highlight`), with their injected JS byte-identical to the +/// legacy `v2Browser*` originals. +extension ControlCommandCoordinator { + /// JavaScript snippet that sets an input element's value using the native + /// prototype setter. Frameworks like React, Vue, and Angular override the + /// value property on instances, so a plain `el.value = x` assignment only + /// updates the DOM without notifying the framework's internal state. + /// Calling the native setter from the prototype bypasses the override and + /// triggers the framework's change-detection when followed by an `input` + /// event. Walks the prototype chain instead of using instanceof so it + /// works with cross-realm elements (iframes) and custom web components. + /// Expects `el` and `newValue` to be in scope. (Static because it is an + /// immutable JS source constant shared by several bodies, exactly as on + /// the legacy controller.) + private static let reactCompatibleSetValue = """ + let nativeSetter = null; + for (let proto = Object.getPrototypeOf(el); proto; proto = Object.getPrototypeOf(proto)) { + const desc = Object.getOwnPropertyDescriptor(proto, 'value'); + if (desc && desc.set) { nativeSetter = desc.set; break; } + } + if (nativeSetter) { + nativeSetter.call(el, newValue); + } else { + el.value = newValue; + } + """ + + /// Reusable JS that dispatches framework-correct input events. Synthetic (untrusted) events do + /// not run native default actions, and many frameworks/libraries listen on the full pointer + + /// mouse sequence (not just `click`) or need legacy KeyboardEvent fields (keyCode/which/code). + /// These helpers reproduce a real user gesture so React, Vue, Svelte, Angular, Solid, and + /// vanilla handlers all fire. Define them once at the top of an injected snippet, then call + /// `__cmuxClick(el)`, `__cmuxHover(el)`, `__cmuxSetChecked(el, desired)`, and `__cmuxKey(t,type,key)`. + /// (Static because it is an immutable JS source constant shared by several + /// bodies, exactly as on the legacy controller.) + private static let browserInputHelpers = """ + function __cmuxCenter(el){const r=el.getBoundingClientRect();return {x:Math.floor(r.left+Math.min(r.width,r.width/2)),y:Math.floor(r.top+Math.min(r.height,r.height/2))};} + function __cmuxPointer(el,type,c,buttons){try{el.dispatchEvent(new PointerEvent(type,{bubbles:true,cancelable:true,composed:true,view:window,pointerId:1,pointerType:'mouse',isPrimary:true,button:0,buttons:buttons,clientX:c.x,clientY:c.y,screenX:c.x,screenY:c.y}));}catch(e){}} + function __cmuxMouse(el,type,c,buttons,detail,bubbles){el.dispatchEvent(new MouseEvent(type,{bubbles:(bubbles===false?false:true),cancelable:true,composed:true,view:window,button:0,buttons:buttons,detail:detail||0,clientX:c.x,clientY:c.y,screenX:c.x,screenY:c.y}));} + function __cmuxClick(el){const c=__cmuxCenter(el); + __cmuxPointer(el,'pointerover',c,0);__cmuxMouse(el,'mouseover',c,0); + __cmuxPointer(el,'pointerenter',c,0);__cmuxMouse(el,'mouseenter',c,0,0,false); + __cmuxPointer(el,'pointermove',c,0);__cmuxMouse(el,'mousemove',c,0); + __cmuxPointer(el,'pointerdown',c,1);__cmuxMouse(el,'mousedown',c,1,1); + if(typeof el.focus==='function'){try{el.focus({preventScroll:true});}catch(e){try{el.focus();}catch(e2){}}} + __cmuxPointer(el,'pointerup',c,0);__cmuxMouse(el,'mouseup',c,0,1); + if(typeof el.click==='function'){el.click();}else{__cmuxMouse(el,'click',c,0,1);} + } + function __cmuxHover(el){const c=__cmuxCenter(el); + __cmuxPointer(el,'pointerover',c,0);__cmuxMouse(el,'mouseover',c,0); + __cmuxPointer(el,'pointerenter',c,0);__cmuxMouse(el,'mouseenter',c,0,0,false); + __cmuxPointer(el,'pointermove',c,0);__cmuxMouse(el,'mousemove',c,0); + } + function __cmuxSetChecked(el,desired){ + // A click event runs the checkbox/radio activation behavior (it TOGGLES a checkbox / SELECTS a + // radio) even when dispatched, and is also what React maps onChange to. So the correct way to + // reach a target state is to click only when it differs; that fires input + change + (React) + // onChange and leaves checked === desired. Setting el.checked directly does not update React's + // controlled state and a separate click would toggle it back. + if(el.checked===desired) return; + // A radio cannot be turned OFF by clicking (clicking a radio only ever selects it). For that + // one case set the property directly via the native setter and notify listeners. + if(desired===false && el.type==='radio'){ + let ns=null; + for(let p=Object.getPrototypeOf(el);p;p=Object.getPrototypeOf(p)){ + const d=Object.getOwnPropertyDescriptor(p,'checked'); if(d&&d.set){ns=d.set;break;} + } + if(ns){ns.call(el,false);}else{el.checked=false;} + el.dispatchEvent(new Event('input',{bubbles:true})); + el.dispatchEvent(new Event('change',{bubbles:true})); + return; + } + if(typeof el.click==='function'){el.click();} + else {const c=__cmuxCenter(el); __cmuxMouse(el,'click',c,0,1);} + } + function __cmuxKeyMeta(key){ + const map={Enter:[13,'Enter'],Tab:[9,'Tab'],Backspace:[8,'Backspace'],Delete:[46,'Delete'],Escape:[27,'Escape'],' ':[32,'Space'],ArrowUp:[38,'ArrowUp'],ArrowDown:[40,'ArrowDown'],ArrowLeft:[37,'ArrowLeft'],ArrowRight:[39,'ArrowRight'],Home:[36,'Home'],End:[35,'End'],PageUp:[33,'PageUp'],PageDown:[34,'PageDown']}; + if(map[key])return {keyCode:map[key][0],code:map[key][1]}; + if(key&&key.length===1){const u=key.toUpperCase(); + if(/[A-Z]/.test(u))return {keyCode:u.charCodeAt(0),code:'Key'+u}; + if(/[0-9]/.test(u))return {keyCode:u.charCodeAt(0),code:'Digit'+u}; + return {keyCode:key.charCodeAt(0),code:''};} + return {keyCode:0,code:key||''}; + } + function __cmuxKey(target,type,key){ + const meta=__cmuxKeyMeta(key); + const ev=new KeyboardEvent(type,{key:key,code:meta.code,location:0,repeat:false,isComposing:false,bubbles:true,cancelable:true,composed:true,view:window}); + try{Object.defineProperty(ev,'keyCode',{get(){return meta.keyCode;}});}catch(e){} + try{Object.defineProperty(ev,'which',{get(){return meta.keyCode;}});}catch(e){} + return target.dispatchEvent(ev); + } + """ + + /// `browser.eval` — evaluate a script in the page (frame-scoped). + func browserEval(_ params: [String: JSONValue]) -> ControlCallResult { + guard let script = string(params, "script") else { + return .err(code: "invalid_params", message: "Missing script", data: nil) + } + return withBrowserPanel(params) { workspaceID, surfaceID in + switch browserRunScript(surfaceID: surfaceID, script: script, timeout: 10.0) { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success(let value): + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + payload["value"] = browserPayloadValue(value) + return .ok(.object(payload)) + } + } + } + + /// `browser.wait` — wait for a selector/url/text/load-state/function + /// condition. + func browserWait(_ params: [String: JSONValue]) -> ControlCallResult { + let timeoutMs = max(1, int(params, "timeout_ms") ?? 5_000) + let selectorRaw = browserSelectorParam(params) + + let conditionScriptBase: String = { + if let urlContains = string(params, "url_contains") { + let literal = browserJSONLiteral(urlContains) + return "String(location.href || '').includes(\(literal))" + } + if let textContains = string(params, "text_contains") { + let literal = browserJSONLiteral(textContains) + return "(document.body && String(document.body.innerText || '').includes(\(literal)))" + } + if let loadState = string(params, "load_state") { + let normalizedLoadState = loadState.lowercased() + if normalizedLoadState == "interactive" { + return """ + (() => { + const __state = String(document.readyState || '').toLowerCase(); + return __state === 'interactive' || __state === 'complete'; + })() + """ + } + let literal = browserJSONLiteral(normalizedLoadState) + return "String(document.readyState || '').toLowerCase() === \(literal)" + } + if let fn = string(params, "function") { + return "(() => { return !!(\(fn)); })()" + } + return "document.readyState === 'complete'" + }() + + let resolution = browserContext?.controlBrowserResolveWaitPanel( + routing: routingSelectors(params), + surfaceID: uuid(params, "surface_id") + ) ?? .tabManagerUnavailable + guard case .resolved(let workspaceID, let surfaceID) = resolution else { + return browserPanelResolutionError(resolution) + } + + let conditionScript: String + if let selectorRaw { + guard let selector = browserContext?.controlBrowserAutomationState + .resolveSelector(selectorRaw, surfaceID: surfaceID) else { + return .err( + code: "not_found", + message: "Element reference not found", + data: .object(["selector": .string(selectorRaw)]) + ) + } + let literal = browserJSONLiteral(selector) + conditionScript = "document.querySelector(\(literal)) !== null" + } else { + conditionScript = conditionScriptBase + } + + if browserWaitForCondition( + surfaceID: surfaceID, + conditionScript: conditionScript, + timeoutMs: timeoutMs + ) { + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + payload["waited"] = .bool(true) + return .ok(.object(payload)) + } + return .err( + code: "timeout", + message: "Condition not met before timeout", + data: .object(["timeout_ms": .int(Int64(timeoutMs))]) + ) + } + + /// `browser.click` — full pointer/mouse click sequence on an element. + func browserClick(_ params: [String: JSONValue]) -> ControlCallResult { + browserSelectorAction(params, actionName: "click") { selectorLiteral in + """ + (() => { + \(Self.browserInputHelpers) + const el = document.querySelector(\(selectorLiteral)); + if (!el) return { ok: false, error: 'not_found' }; + if (el.disabled) return { ok: false, error: 'disabled' }; + el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + __cmuxClick(el); + return { ok: true }; + })() + """ + } + } + + /// `browser.dblclick` — double-click an element. + func browserDblClick(_ params: [String: JSONValue]) -> ControlCallResult { + browserSelectorAction(params, actionName: "dblclick") { selectorLiteral in + """ + (() => { + \(Self.browserInputHelpers) + const el = document.querySelector(\(selectorLiteral)); + if (!el) return { ok: false, error: 'not_found' }; + if (el.disabled) return { ok: false, error: 'disabled' }; + el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + __cmuxClick(el); + __cmuxClick(el); + const c = __cmuxCenter(el); + __cmuxMouse(el, 'dblclick', c, 0, 2); + return { ok: true }; + })() + """ + } + } + + /// `browser.hover` — hover an element. + func browserHover(_ params: [String: JSONValue]) -> ControlCallResult { + browserSelectorAction(params, actionName: "hover") { selectorLiteral in + """ + (() => { + \(Self.browserInputHelpers) + const el = document.querySelector(\(selectorLiteral)); + if (!el) return { ok: false, error: 'not_found' }; + el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + __cmuxHover(el); + return { ok: true }; + })() + """ + } + } + + /// `browser.focus` — focus an element. + func browserFocusElement(_ params: [String: JSONValue]) -> ControlCallResult { + browserSelectorAction(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 }; + })() + """ + } + } + + /// `browser.type` — append text to an element's value. + func browserType(_ params: [String: JSONValue]) -> ControlCallResult { + guard let text = string(params, "text") else { + return .err(code: "invalid_params", message: "Missing text", data: nil) + } + return browserSelectorAction(params, actionName: "type") { selectorLiteral in + let textLiteral = browserJSONLiteral(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) { + const newValue = (el.value || '') + chunk; + // beforeinput is cancelable; honor a page that rejects the edit (input masks, + // controlled editors) instead of forcing the value and drifting from app state. + let proceed = true; + try { proceed = el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertText', data: chunk })); } catch (e) {} + if (!proceed) return { ok: false, error: 'input_rejected' }; + \(Self.reactCompatibleSetValue) + try { el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: chunk })); } + catch (e) { el.dispatchEvent(new Event('input', { bubbles: true })); } + el.dispatchEvent(new Event('change', { bubbles: true })); + } else { + el.textContent = (el.textContent || '') + chunk; + try { el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: chunk })); } catch (e) {} + } + return { ok: true }; + })() + """ + } + } + + /// `browser.fill` — replace an element's value (empty string allowed, so + /// callers can clear inputs). + func browserFill(_ params: [String: JSONValue]) -> ControlCallResult { + // `fill` must allow empty strings so callers can clear existing input values. + guard let text = rawString(params, "text") ?? rawString(params, "value") else { + return .err(code: "invalid_params", message: "Missing text/value", data: nil) + } + return browserSelectorAction(params, actionName: "fill") { selectorLiteral in + let textLiteral = browserJSONLiteral(text) + return """ + (() => { + const el = document.querySelector(\(selectorLiteral)); + if (!el) return { ok: false, error: 'not_found' }; + if (typeof el.focus === 'function') el.focus(); + const newValue = String(\(textLiteral)); + if ('value' in el) { + // beforeinput is cancelable; honor a page that rejects the edit instead of forcing + // the value and drifting from app state. + let proceed = true; + try { proceed = el.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, cancelable: true, inputType: 'insertReplacementText', data: newValue })); } catch (e) {} + if (!proceed) return { ok: false, error: 'input_rejected' }; + \(Self.reactCompatibleSetValue) + try { el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertReplacementText', data: newValue })); } + catch (e) { el.dispatchEvent(new Event('input', { bubbles: true })); } + el.dispatchEvent(new Event('change', { bubbles: true })); + } else { + el.textContent = newValue; + try { el.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertReplacementText', data: newValue })); } catch (e) {} + } + return { ok: true }; + })() + """ + } + } + + /// `browser.press` — full keydown/keypress/keyup on the active element, + /// with the native implicit form-submission mirror for Enter. + func browserPress(_ params: [String: JSONValue]) -> ControlCallResult { + guard let key = string(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + + return withBrowserPanel(params) { workspaceID, surfaceID in + let keyLiteral = browserJSONLiteral(key) + let script = """ + (() => { + \(Self.browserInputHelpers) + const target = document.activeElement || document.body || document.documentElement; + if (!target) return { ok: false, error: 'not_found' }; + const k = String(\(keyLiteral)); + const kdNotPrevented = __cmuxKey(target, 'keydown', k); + // keypress historically fires for character-producing keys, which includes Enter and + // Space; many pages still bind submit/search to keypress for Enter. + let kpNotPrevented = true; + if (k.length === 1 || k === 'Enter') { kpNotPrevented = __cmuxKey(target, 'keypress', k); } + __cmuxKey(target, 'keyup', k); + // Synthetic key events do not run WebKit's native "Enter submits the form" default + // action. Mirror real-user behavior, but only when neither keydown nor keypress was + // canceled (pages cancel Enter to run their own handling) and the native HTML implicit + // submission rules would apply: focus is a single-line text-like field AND the form has + // a submit control or exactly one such field. + if (k === 'Enter' && kdNotPrevented && kpNotPrevented && target && target.tagName === 'INPUT' && target.form) { + const submitTypes = ['text','search','email','url','tel','password','number','date','datetime-local','month','week','time']; + if (submitTypes.indexOf((target.type || 'text').toLowerCase()) !== -1) { + const hasSubmit = !!target.form.querySelector('input[type=submit],input[type=image],button[type=submit],button:not([type])'); + const textFields = target.form.querySelectorAll('input[type=text],input[type=search],input[type=email],input[type=url],input[type=tel],input[type=password],input[type=number],input[type=date],input[type=datetime-local],input[type=month],input[type=week],input[type=time],input:not([type])'); + if (hasSubmit || textFields.length === 1) { + try { if (target.form.requestSubmit) { target.form.requestSubmit(); } else { target.form.submit(); } } catch (e) {} + } + } + } + return { ok: true }; + })() + """ + switch browserRunScript(surfaceID: surfaceID, script: script) { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success: + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + browserAppendPostSnapshot(params, surfaceID: surfaceID, payload: &payload) + return .ok(.object(payload)) + } + } + } + + /// `browser.keydown` — dispatch a keydown on the active element. + func browserKeyDown(_ params: [String: JSONValue]) -> ControlCallResult { + guard let key = string(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + return withBrowserPanel(params) { workspaceID, surfaceID in + let keyLiteral = browserJSONLiteral(key) + let script = """ + (() => { + \(Self.browserInputHelpers) + const target = document.activeElement || document.body || document.documentElement; + if (!target) return { ok: false, error: 'not_found' }; + const k = String(\(keyLiteral)); + __cmuxKey(target, 'keydown', k); + return { ok: true }; + })() + """ + switch browserRunScript(surfaceID: surfaceID, script: script) { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success: + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + browserAppendPostSnapshot(params, surfaceID: surfaceID, payload: &payload) + return .ok(.object(payload)) + } + } + } + + /// `browser.keyup` — dispatch a keyup on the active element. + func browserKeyUp(_ params: [String: JSONValue]) -> ControlCallResult { + guard let key = string(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + return withBrowserPanel(params) { workspaceID, surfaceID in + let keyLiteral = browserJSONLiteral(key) + let script = """ + (() => { + \(Self.browserInputHelpers) + const target = document.activeElement || document.body || document.documentElement; + if (!target) return { ok: false, error: 'not_found' }; + const k = String(\(keyLiteral)); + __cmuxKey(target, 'keyup', k); + return { ok: true }; + })() + """ + switch browserRunScript(surfaceID: surfaceID, script: script) { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success: + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + browserAppendPostSnapshot(params, surfaceID: surfaceID, payload: &payload) + return .ok(.object(payload)) + } + } + } + + /// `browser.check` / `browser.uncheck` — drive a checkbox/radio to the + /// target state with framework-correct events. + func browserCheck(_ params: [String: JSONValue], checked: Bool) -> ControlCallResult { + browserSelectorAction(params, actionName: checked ? "check" : "uncheck") { selectorLiteral in + """ + (() => { + \(Self.browserInputHelpers) + const el = document.querySelector(\(selectorLiteral)); + if (!el) return { ok: false, error: 'not_found' }; + if (!('checked' in el)) return { ok: false, error: 'not_checkable' }; + if (el.disabled) return { ok: false, error: 'disabled' }; + el.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + if (typeof el.focus === 'function') { try { el.focus({ preventScroll: true }); } catch (e) {} } + __cmuxSetChecked(el, \(checked ? "true" : "false")); + if (el.checked !== \(checked ? "true" : "false")) return { ok: false, error: 'not_changed' }; + return { ok: true }; + })() + """ + } + } + + /// `browser.select` — set a select/option value. + func browserSelect(_ params: [String: JSONValue]) -> ControlCallResult { + let selectedValue = string(params, "value") ?? string(params, "text") + guard let selectedValue else { + return .err(code: "invalid_params", message: "Missing value", data: nil) + } + return browserSelectorAction(params, actionName: "select") { selectorLiteral in + let valueLiteral = browserJSONLiteral(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' }; + const newValue = String(\(valueLiteral)); + \(Self.reactCompatibleSetValue) + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return { ok: true }; + })() + """ + } + } + + /// `browser.scroll` — scroll the window or an element by a delta. + func browserScroll(_ params: [String: JSONValue]) -> ControlCallResult { + let dx = int(params, "dx") ?? 0 + let dy = int(params, "dy") ?? 0 + let selectorRaw = browserSelectorParam(params) + + return withBrowserPanel(params) { workspaceID, surfaceID in + let selector = selectorRaw.flatMap { + browserContext?.controlBrowserAutomationState.resolveSelector($0, surfaceID: surfaceID) + } + if selectorRaw != nil && selector == nil { + return .err( + code: "not_found", + message: "Element reference not found", + data: .object(["selector": .string(selectorRaw ?? "")]) + ) + } + + let script: String + if let selector { + let selectorLiteral = browserJSONLiteral(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 browserRunScript(surfaceID: surfaceID, script: script) { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success(let value): + if let dict = browserScriptObject(value), + browserExactBool(dict["ok"]) == false, + browserStringValue(dict["error"]) == "not_found" { + if let selector { + return browserElementNotFoundResult( + actionName: "scroll", + selector: selector, + attempts: 1, + surfaceID: surfaceID + ) + } + return .err( + code: "not_found", + message: "Element not found", + data: .object(["selector": .string(selector ?? "")]) + ) + } + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + browserAppendPostSnapshot(params, surfaceID: surfaceID, payload: &payload) + return .ok(.object(payload)) + } + } + } + + /// `browser.scroll_into_view` — center an element in the viewport. + func browserScrollIntoView(_ params: [String: JSONValue]) -> ControlCallResult { + browserSelectorAction(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 }; + })() + """ + } + } + + /// `browser.screenshot` — capture the visible viewport as PNG, returning + /// base64 plus a best-effort temp-file path. + func browserScreenshot(_ params: [String: JSONValue]) -> ControlCallResult { + withBrowserPanel(params) { workspaceID, surfaceID in + let capture = browserContext?.controlBrowserCaptureScreenshot(surfaceID: surfaceID) ?? .captureFailed + let imageData: Data + switch capture { + case .timedOut: + return .err(code: "timeout", message: "Timed out waiting for snapshot", data: nil) + case .captureFailed: + return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil) + case .png(let data): + imageData = data + } + + var result = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + result["png_base64"] = .string(imageData.base64EncodedString()) + + // Best effort: keep screenshot data available even when temp-file writes fail. + let screenshotsDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-browser-screenshots", isDirectory: true) + if (try? FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true)) != nil { + browserBestEffortPruneTemporaryFiles(in: screenshotsDirectory) + let timestampMs = Int(Date().timeIntervalSince1970 * 1000) + let shortSurfaceId = String(surfaceID.uuidString.prefix(8)) + let shortRandomId = String(UUID().uuidString.prefix(8)) + let filename = "surface-\(shortSurfaceId)-\(timestampMs)-\(shortRandomId).png" + let imageURL = screenshotsDirectory.appendingPathComponent(filename, isDirectory: false) + if (try? imageData.write(to: imageURL, options: .atomic)) != nil { + result["path"] = .string(imageURL.path) + result["url"] = .string(imageURL.absoluteString) + } + } + + return .ok(.object(result)) + } + } + + /// Trims the screenshot temp directory (was + /// `bestEffortPruneTemporaryFiles`, whose only caller was the screenshot + /// body): keeps the newest `maxCount` regular files and drops anything + /// older than `maxAge`. + private func browserBestEffortPruneTemporaryFiles( + in directoryURL: URL, + keepingMostRecent maxCount: Int = 50, + maxAge: TimeInterval = 24 * 60 * 60 + ) { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: directoryURL, + includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey], + options: [.skipsHiddenFiles] + ) else { + return + } + + let now = Date() + let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in + guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]), + values.isRegularFile == true else { + return nil + } + return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast) + }.sorted { $0.date > $1.date } + + for (index, entry) in datedEntries.enumerated() { + if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge { + try? FileManager.default.removeItem(at: entry.url) + } + } + } + + /// `browser.highlight` — flash an outline around an element. + func browserHighlight(_ params: [String: JSONValue]) -> ControlCallResult { + browserSelectorAction(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 }; + })() + """ + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationDialogsScripts.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationDialogsScripts.swift new file mode 100644 index 000000000000..f83f6cbbdfa4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationDialogsScripts.swift @@ -0,0 +1,160 @@ +internal import Foundation + +/// The browser dialog bodies (`browser.dialog.accept`/`dismiss`) and script +/// injection bodies (`browser.addinitscript`/`addscript`/`addstyle`), with +/// their injected JS byte-identical to the legacy originals. +extension ControlCommandCoordinator { + /// `browser.dialog.accept` / `browser.dialog.dismiss` — resolve the + /// oldest hooked page dialog (the page-world `__cmuxDialogQueue`), and + /// record confirm/prompt defaults for future dialogs. + func browserDialogRespond(_ params: [String: JSONValue], accept: Bool) -> ControlCallResult { + return withBrowserPanel(params) { workspaceID, surfaceID in + browserContext?.controlBrowserEnsureTelemetryHooks(surfaceID: surfaceID) + browserContext?.controlBrowserEnsureDialogHooks(surfaceID: surfaceID) + let text = string(params, "text") ?? string(params, "prompt_text") + let acceptLiteral = accept ? "true" : "false" + let textLiteral = text.map(browserJSONLiteral) ?? "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 }; + })() + """ + + let outcome = browserContext?.controlBrowserRunPageScript( + surfaceID: surfaceID, + script: script, + timeout: 5.0 + ) ?? .failure("Browser operation failed") + + switch outcome { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success(let value): + guard let dict = browserScriptObject(value), + browserExactBool(dict["ok"]) == true else { + let pending = browserPendingDialogSummaries(surfaceID: surfaceID) + return .err( + code: "not_found", + message: "No pending dialog", + data: .object(["pending": .array(pending)]) + ) + } + + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + payload["accepted"] = .bool(accept) + payload["dialog"] = dict["dialog"] ?? .null + payload["remaining"] = dict["remaining"] ?? .null + return .ok(.object(payload)) + } + } + } + + /// The pending native-dialog summaries for the `No pending dialog` + /// diagnostic (was `v2BrowserPendingDialogs`; keys identical). + func browserPendingDialogSummaries(surfaceID: UUID) -> [JSONValue] { + let queue = browserContext?.controlBrowserAutomationState.pendingDialogs(forSurface: surfaceID) ?? [] + return queue.enumerated().map { index, dialog in + .object([ + "index": .int(Int64(index)), + "type": .string(dialog.kind), + "message": .string(dialog.message), + "default_text": orNull(dialog.defaultText), + ]) + } + } + + /// Pops the oldest pending native dialog for a surface and runs its + /// app-side completion handler through the seam (the redesigned twin of + /// the legacy `v2BrowserPopDialog` + responder closure; like the + /// original pop, no command body calls it yet — the native queue exists + /// for the WKUIDelegate enqueue path). + @discardableResult + func browserResolveNextNativeDialog(surfaceID: UUID, accept: Bool, text: String?) -> ControlBrowserPendingDialog? { + guard let browserContext, + let dialog = browserContext.controlBrowserAutomationState.popDialog(forSurface: surfaceID) else { + return nil + } + _ = browserContext.controlBrowserResolvePendingDialog(dialogID: dialog.dialogID, accept: accept, text: text) + return dialog + } + + /// `browser.addinitscript` — record a script, install it as a persistent + /// document-start user script, and run it once now. + func browserAddInitScript(_ params: [String: JSONValue]) -> ControlCallResult { + guard let script = string(params, "script") ?? string(params, "content") else { + return .err(code: "invalid_params", message: "Missing script", data: nil) + } + return withBrowserPanel(params) { workspaceID, surfaceID in + let scriptCount = browserContext?.controlBrowserAutomationState + .appendInitScript(script, forSurface: surfaceID) ?? 0 + + browserContext?.controlBrowserAddPersistentUserScript(surfaceID: surfaceID, source: script) + _ = browserRunScript(surfaceID: surfaceID, script: script, timeout: 10.0) + + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + payload["scripts"] = .int(Int64(scriptCount)) + return .ok(.object(payload)) + } + } + + /// `browser.addscript` — run a script once and return its value. + func browserAddScript(_ params: [String: JSONValue]) -> ControlCallResult { + guard let script = string(params, "script") ?? string(params, "content") else { + return .err(code: "invalid_params", message: "Missing script", data: nil) + } + return withBrowserPanel(params) { workspaceID, surfaceID in + switch browserRunScript(surfaceID: surfaceID, script: script, timeout: 10.0) { + case .failure(let message): + return .err(code: "js_error", message: message, data: nil) + case .success(let value): + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + payload["value"] = browserPayloadValue(value) + return .ok(.object(payload)) + } + } + } + + /// `browser.addstyle` — record a stylesheet, install it as a persistent + /// document-start user script, and apply it once now. + func browserAddStyle(_ params: [String: JSONValue]) -> ControlCallResult { + guard let css = string(params, "css") ?? string(params, "style") ?? string(params, "content") else { + return .err(code: "invalid_params", message: "Missing css/style content", data: nil) + } + return withBrowserPanel(params) { workspaceID, surfaceID in + let styleCount = browserContext?.controlBrowserAutomationState + .appendInitStyle(css, forSurface: surfaceID) ?? 0 + + let cssLiteral = browserJSONLiteral(css) + let source = """ + (() => { + const el = document.createElement('style'); + el.textContent = String(\(cssLiteral)); + (document.head || document.documentElement || document.body).appendChild(el); + return true; + })() + """ + + browserContext?.controlBrowserAddPersistentUserScript(surfaceID: surfaceID, source: source) + _ = browserRunScript(surfaceID: surfaceID, script: source, timeout: 10.0) + + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + payload["styles"] = .int(Int64(styleCount)) + return .ok(.object(payload)) + } + } +} diff --git a/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationFind.swift b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationFind.swift new file mode 100644 index 000000000000..2a84fb7b61f4 --- /dev/null +++ b/Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Browser/ControlCommandCoordinator+BrowserAutomationFind.swift @@ -0,0 +1,563 @@ +internal import Foundation + +/// The browser element-finder bodies (`browser.find.*`) and frame selection +/// (`browser.frame.select` / `browser.frame.main`), with their injected JS +/// byte-identical to the legacy `v2BrowserFind*` / `v2BrowserFrame*` +/// originals. +extension ControlCommandCoordinator { + /// Runs a finder body inside the shared CSS-path wrapper, mints an `@eN` + /// element ref for the match, and shapes the standard find payload (twin + /// of `v2BrowserFindWithScript`). + private func browserFindWithScript( + _ params: [String: JSONValue], + actionName: String, + finderBody: String, + metadata: [String: JSONValue] = [:] + ) -> ControlCallResult { + return withBrowserPanel(params) { workspaceID, surfaceID 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 browserRunScript(surfaceID: surfaceID, script: script) { + case .failure(let message): + return .err(code: "js_error", message: message, data: .object(["action": .string(actionName)])) + case .success(let value): + guard let dict = browserScriptObject(value), + browserExactBool(dict["ok"]) == true, + let selector = browserStringValue(dict["selector"]), + !selector.isEmpty else { + return .err(code: "not_found", message: "Element not found", data: .object(metadata)) + } + + let elementRef = browserContext?.controlBrowserAutomationState + .allocateElementRef(surfaceID: surfaceID, selector: selector) ?? "" + var payload = browserIdentityPayload(workspaceID: workspaceID, surfaceID: surfaceID) + payload["action"] = .string(actionName) + payload["selector"] = .string(selector) + payload["element_ref"] = .string(elementRef) + payload["ref"] = .string(elementRef) + for (key, metadataValue) in metadata { + payload[key] = metadataValue + } + if let tag = browserStringValue(dict["tag"]) { + payload["tag"] = .string(tag) + } + if let text = browserStringValue(dict["text"]) { + payload["text"] = .string(text) + } + return .ok(.object(payload)) + } + } + } + + /// `browser.find.role` — find by ARIA role (explicit or implicit) and + /// optional accessible name. + func browserFindRole(_ params: [String: JSONValue]) -> ControlCallResult { + guard let role = (string(params, "role") ?? string(params, "value"))?.lowercased() else { + return .err(code: "invalid_params", message: "Missing role", data: nil) + } + let name = string(params, "name")?.lowercased() + let exact = bool(params, "exact") ?? false + let roleLiteral = browserJSONLiteral(role) + let nameLiteral = name.map(browserJSONLiteral) ?? "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 browserFindWithScript( + params, + actionName: "find.role", + finderBody: finder, + metadata: [ + "role": .string(role), + "name": orNull(name), + "exact": .bool(exact), + ] + ) + } + + /// `browser.find.text` — find by (normalized) text content. + func browserFindText(_ params: [String: JSONValue]) -> ControlCallResult { + guard let text = (string(params, "text") ?? string(params, "value"))?.lowercased() else { + return .err(code: "invalid_params", message: "Missing text", data: nil) + } + let exact = bool(params, "exact") ?? false + let textLiteral = browserJSONLiteral(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 browserFindWithScript( + params, + actionName: "find.text", + finderBody: finder, + metadata: ["text": .string(text), "exact": .bool(exact)] + ) + } + + /// `browser.find.label` — find the control a `