diff --git a/CLI/CMUXCLI+CommandSuggestions.swift b/CLI/CMUXCLI+CommandSuggestions.swift index 2fe4952d719..e5cbbc1f474 100644 --- a/CLI/CMUXCLI+CommandSuggestions.swift +++ b/CLI/CMUXCLI+CommandSuggestions.swift @@ -155,6 +155,7 @@ extension CMUXCLI { "popup", "previous-window", "read-screen", + "read-selection", "refresh-surfaces", "reload-config", "remote-daemon-status", diff --git a/CLI/CMUXCLI+SurfaceSelection.swift b/CLI/CMUXCLI+SurfaceSelection.swift new file mode 100644 index 00000000000..35efdcb2295 --- /dev/null +++ b/CLI/CMUXCLI+SurfaceSelection.swift @@ -0,0 +1,217 @@ +import Foundation + +extension CMUXCLI { + func runSurfaceSelectionCommand( + commandName: String, + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + windowOverride: String?, + includeContextInPlainOutput: Bool + ) throws { + let (workspaceOption, remainingAfterWorkspace) = parseOption( + commandArgs, + name: "--workspace" + ) + let (surfaceOption, remainingAfterSurface) = parseOption( + remainingAfterWorkspace, + name: "--surface" + ) + let (windowOption, trailing) = parseOption( + remainingAfterSurface, + name: "--window" + ) + guard trailing.isEmpty else { + throw CLIError(message: String( + format: String( + localized: "cli.readSelection.error.unexpectedArguments", + defaultValue: "%@: unexpected arguments: %@" + ), + commandName, + trailing.joined(separator: " ") + )) + } + + let windowRaw = windowOption ?? windowOverride + let workspaceRaw = workspaceOption + ?? Self.callerWorkspaceForSurfaceHandle(surfaceOption, windowRaw: windowRaw) + let surfaceRaw = surfaceOption + ?? (workspaceOption == nil && windowRaw == nil + ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] + : nil) + + var params: [String: Any] = [:] + let windowID = try normalizeWindowHandle(windowRaw, client: client) + if let windowID { + params["window_id"] = windowID + } + let workspaceID = try normalizeWorkspaceHandle( + workspaceRaw, + client: client, + windowHandle: windowID + ) + if let workspaceID { + params["workspace_id"] = workspaceID + } + let surfaceID = try normalizeSurfaceHandle( + surfaceRaw, + client: client, + workspaceHandle: workspaceID, + windowHandle: windowID + ) + if let surfaceID { + params["surface_id"] = surfaceID + } + + let payload = try client.sendV2( + method: "surface.read_selection", + params: params + ) + if jsonOutput { + print(jsonString(payload)) + return + } + guard (payload["has_selection"] as? Bool) == true else { + if includeContextInPlainOutput { + let metadata = surfaceSelectionMetadataLines(payload) + if !metadata.isEmpty { + print(metadata.joined(separator: "\n")) + print("") + } + } + print(String( + localized: "cli.readSelection.output.noActiveSelection", + defaultValue: "Has selection: false" + )) + return + } + + let text = (payload["text"] as? String) ?? "" + guard includeContextInPlainOutput else { + print(text) + return + } + + let metadata = surfaceSelectionMetadataLines(payload) + if !metadata.isEmpty { + print(metadata.joined(separator: "\n")) + print("") + } + print(text) + } + + private func surfaceSelectionMetadataLines( + _ payload: [String: Any] + ) -> [String] { + var lines: [String] = [] + if let kind = payload["kind"] as? String, !kind.isEmpty { + lines.append(String( + format: String( + localized: "cli.readSelection.output.kind", + defaultValue: "Kind: %@" + ), + kind + )) + } + if let filePath = payload["file_path"] as? String, !filePath.isEmpty { + lines.append(String( + format: String( + localized: "cli.readSelection.output.file", + defaultValue: "File: %@" + ), + filePath + )) + } + if let range = payload["line_range"] as? [String: Any], + let start = surfaceSelectionLineNumber(range["start"]), + let end = surfaceSelectionLineNumber(range["end"]) { + if start == end { + lines.append(String( + format: String( + localized: "cli.readSelection.output.line", + defaultValue: "Line: %lld" + ), + Int64(start) + )) + } else { + lines.append(String( + format: String( + localized: "cli.readSelection.output.lines", + defaultValue: "Lines: %lld-%lld" + ), + Int64(start), + Int64(end) + )) + } + } + if let url = payload["url"] as? String, !url.isEmpty { + lines.append(String( + format: String( + localized: "cli.readSelection.output.url", + defaultValue: "URL: %@" + ), + url + )) + } + return lines + } + + private func surfaceSelectionLineNumber(_ value: Any?) -> Int? { + if let value = value as? Int { + return value + } + return (value as? NSNumber)?.intValue + } + + static var readSelectionHelp: String { + String(localized: "cli.help.readSelection", defaultValue: """ + Usage: cmux read-selection [flags] + + Read the active selection from any selectable surface. Plain output includes source context; --json returns the complete response. + + Flags: + --workspace Target workspace (default: $CMUX_WORKSPACE_ID) + --surface Target surface (default: $CMUX_SURFACE_ID) + --window Window context for workspace/surface refs and indexes + + Example: + cmux read-selection --surface surface:2 + cmux read-selection --surface surface:2 --json + """) + } + + static var readScreenHelp: String { + String(localized: "cli.help.readScreen", defaultValue: """ + Usage: cmux read-screen [flags] + + Read terminal text from a surface as plain text. + + Flags: + --workspace Target workspace (default: $CMUX_WORKSPACE_ID) + --surface Target surface (default: $CMUX_SURFACE_ID) + --window Window context for workspace/surface refs and indexes + --scrollback Include scrollback (not just visible viewport) + --lines Limit to the last n lines (implies --scrollback) + --selection Read only the active selection; cannot be combined with --scrollback or --lines + + Example: + cmux read-screen + cmux read-screen --surface surface:2 --scrollback --lines 200 + cmux read-screen --surface surface:2 --selection + """) + } + + static var readSelectionUsageLine: String { + String( + localized: "cli.usage.readSelection", + defaultValue: "read-selection [--workspace ] [--surface ] [--window ]" + ) + } + + static var readScreenUsageLine: String { + String( + localized: "cli.usage.readScreen", + defaultValue: "read-screen [--workspace ] [--surface ] [--window ] [--scrollback] [--lines ] [--selection]" + ) + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6e9849fea97..11502aac846 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4861,6 +4861,14 @@ struct CMUXCLI { if normalizedCommand == "restore" { return false } + if normalizedCommand == "read-screen" || normalizedCommand == "read-selection" { + return false + } + if normalizedCommand == "rpc", + commandArgs.first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + == "surface.read_selection" { + return false + } if normalizedCommand == "surface", commandArgs.first?.lowercased() == "resume" { return false } @@ -6617,7 +6625,14 @@ struct CMUXCLI { !method.isEmpty else { throw CLIError(message: "Usage: cmux rpc [json-params]") } - let params = try parseRPCParams(Array(commandArgs.dropFirst())) + var params = try parseRPCParams(Array(commandArgs.dropFirst())) + if method.lowercased() == "surface.read_selection", + let windowId, + params["window_id"] == nil || params["window_id"] is NSNull { + // Selection reads must not focus the target window, but an + // explicit global --window still constrains routing to it. + params["window_id"] = try normalizeWindowHandle(windowId, client: client) ?? windowId + } let response = try client.sendV2(method: method, params: params) let output: Any = idFormatArg == nil ? response : formatIDs(response, mode: idFormat) print(jsonString(output)) @@ -7356,7 +7371,40 @@ struct CMUXCLI { print(handle) } + case "read-selection": + try runSurfaceSelectionCommand( + commandName: "read-selection", + commandArgs: commandArgs, + client: client, + jsonOutput: jsonOutput, + windowOverride: windowId, + includeContextInPlainOutput: true + ) + case "read-screen": + let selectionOnly = commandArgs.contains("--selection") + if selectionOnly { + let hasScrollback = commandArgs.contains("--scrollback") + let hasLines = commandArgs.contains { + $0 == "--lines" || $0.hasPrefix("--lines=") + } + if hasScrollback || hasLines { + throw CLIError(message: String( + localized: "cli.readSelection.error.readScreenConflict", + defaultValue: "read-screen: --selection cannot be combined with --scrollback or --lines" + )) + } + try runSurfaceSelectionCommand( + commandName: "read-screen", + commandArgs: commandArgs.filter { $0 != "--selection" }, + client: client, + jsonOutput: jsonOutput, + windowOverride: windowId, + includeContextInPlainOutput: false + ) + break + } + let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace") let (sfArg, rem1) = parseOption(rem0, name: "--surface") let (windowOpt, rem2) = parseOption(rem1, name: "--window") @@ -20027,23 +20075,10 @@ struct CMUXCLI { Flags: -p, --print Print to stdout only """ + case "read-selection": + return Self.readSelectionHelp case "read-screen": - return """ - Usage: cmux read-screen [flags] - - Read terminal text from a surface as plain text. - - Flags: - --workspace Target workspace (default: $CMUX_WORKSPACE_ID) - --surface Target surface (default: $CMUX_SURFACE_ID) - --window Window context for workspace/surface refs and indexes - --scrollback Include scrollback (not just visible viewport) - --lines Limit to the last n lines (implies --scrollback) - - Example: - cmux read-screen - cmux read-screen --surface surface:2 --scrollback --lines 200 - """ + return Self.readScreenHelp case "send": return """ Usage: cmux send [flags] [--] @@ -40981,7 +41016,8 @@ export default CMUXSessionRestore; rename-workspace [--workspace ] [--window ] rename-window [--workspace <id|ref|index>] [--window <id|ref|index>] <title> current-workspace [--window <id|ref|index>] - read-screen [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--scrollback] [--lines <n>] + \(Self.readSelectionUsageLine) + \(Self.readScreenUsageLine) send [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] <text> send-key [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] <key> send-panel --panel <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>] <text> diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift index f286018ea16..e6fc8722b51 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/ControlCommandCoordinator.swift @@ -211,6 +211,21 @@ public final class ControlCommandCoordinator { handles.removeRef(kind: kind, uuid: uuid) } + /// Returns whether an opaque-handle topology refresh is needed. + public var needsHandleTopologyRefresh: Bool { + handles.needsTopologyRefresh + } + + /// Records completion of the current opaque-handle topology refresh. + public func markHandleTopologyRefreshCompleted() { + handles.markTopologyRefreshCompleted() + } + + /// Reopens opaque-handle refresh after an external topology mutation. + public func invalidateHandleTopologyRefresh() { + handles.invalidateTopologyRefresh() + } + // MARK: - Wire helpers /// The `kind:N` ref for an optional id as a JSON value: the ref string, or diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy+ReadPlane.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy+ReadPlane.swift index ef6cc3c3711..f7ae5cf73f7 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy+ReadPlane.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy+ReadPlane.swift @@ -35,6 +35,7 @@ extension ControlCommandExecutionPolicy { "surface.list", "surface.current", "surface.read_text", + "surface.read_selection", "pane.list", "pane.surfaces", "list_windows", diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 30b83a38c0e..940416df934 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -134,6 +134,10 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { // never runs inline on the main thread, and no in-process main-thread // caller needs it. "surface.read_text", + // Selection providers own AppKit/WebKit state on the main actor, then + // return one immutable snapshot for response shaping on this worker. + // The async bridge must never be entered inline by a main-thread caller. + "surface.read_selection", // The surface catalog verbs await main-actor catalog work that can sit on the // network (a cloud provider materializing a pane); like `vm.*` they park the // worker instead of holding the main actor. diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlHandleRegistry.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlHandleRegistry.swift index 367847df090..f9428c98df3 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlHandleRegistry.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlHandleRegistry.swift @@ -11,6 +11,7 @@ public struct ControlHandleRegistry: Sendable { private var nextOrdinal: [ControlHandleKind: Int] private var refByUUID: [ControlHandleKind: [UUID: String]] private var uuidByRef: [ControlHandleKind: [String: UUID]] + private var topologyRefreshNeeded: Bool /// Creates an empty registry with all ordinals starting at 1. public init() { @@ -25,6 +26,7 @@ public struct ControlHandleRegistry: Sendable { nextOrdinal = ordinals refByUUID = byUUID uuidByRef = byRef + topologyRefreshNeeded = true } /// Returns the existing ref for an object, minting the next @@ -55,9 +57,8 @@ public struct ControlHandleRegistry: Sendable { /// - kind: The handle kind. /// - uuid: The object identity to forget. public mutating func removeRef(kind: ControlHandleKind, uuid: UUID) { - if let ref = refByUUID[kind]?[uuid] { - uuidByRef[kind]?.removeValue(forKey: ref) - } + guard let ref = refByUUID[kind]?[uuid] else { return } + uuidByRef[kind]?.removeValue(forKey: ref) refByUUID[kind]?.removeValue(forKey: uuid) } @@ -83,4 +84,23 @@ public struct ControlHandleRegistry: Sendable { } return nil } + + /// Returns whether a fallback topology refresh is currently needed. + /// + /// Membership notifications from the app invalidate the completed claim, + /// so a repeated stale opaque ref does not rescan the entire app on every + /// poll. Ref minting itself does not reopen the claim: the command that + /// minted a ref already made that ref resolvable. + /// + public var needsTopologyRefresh: Bool { topologyRefreshNeeded } + + /// Records that the live topology has been swept. + public mutating func markTopologyRefreshCompleted() { + topologyRefreshNeeded = false + } + + /// Reopens the fallback refresh claim after an external topology mutation. + public mutating func invalidateTopologyRefresh() { + topologyRefreshNeeded = true + } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 74329a64255..108310f84fa 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -159,6 +159,7 @@ struct ControlCommandExecutionPolicyTests { // that formatting inline on the main thread, which is exactly the // stall the lane move removes, and no in-process caller needs it. #expect(ControlCommandExecutionPolicy(forMethod: "surface.read_text") == .socketWorker(mainThreadCallable: false)) + #expect(ControlCommandExecutionPolicy(forMethod: "surface.read_selection") == .socketWorker(mainThreadCallable: false)) #expect(ControlCommandExecutionPolicy(forV1Command: "read_screen") == .socketWorker(mainThreadCallable: false)) } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlHandleRegistryTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlHandleRegistryTests.swift index a148261f0ad..431dfdede57 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlHandleRegistryTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlHandleRegistryTests.swift @@ -58,4 +58,16 @@ struct ControlHandleRegistryTests { #expect(registry.uuid(forRef: "tab:2") == nil) #expect(registry.uuid(forRef: "tab:x") == nil) } + + @Test func topologyRefreshClaimCoalescesWithinOneSnapshotGeneration() { + var registry = ControlHandleRegistry() + #expect(registry.needsTopologyRefresh) + registry.markTopologyRefreshCompleted() + #expect(!registry.needsTopologyRefresh) + _ = registry.ensureRef(kind: .surface, uuid: UUID()) + #expect(!registry.needsTopologyRefresh) + registry.markTopologyRefreshCompleted() + registry.invalidateTopologyRefresh() + #expect(registry.needsTopologyRefresh) + } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlPlaneConcurrencyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlPlaneConcurrencyTests.swift index 89b6208b070..8528919f182 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlPlaneConcurrencyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlPlaneConcurrencyTests.swift @@ -216,6 +216,9 @@ struct ControlPlaneConcurrencyTests { #expect( ControlCommandExecutionPolicy.pollingMethods.contains("system.top") ) + #expect( + ControlCommandExecutionPolicy.pollingMethods.contains("surface.read_selection") + ) #expect( !ControlCommandExecutionPolicy.pollingMethods.contains("workspace.create") ) diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeSelectionRequest.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeSelectionRequest.swift new file mode 100644 index 00000000000..70aa79b9b63 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeSelectionRequest.swift @@ -0,0 +1,40 @@ +internal import Foundation +internal import GhosttyKit + +/// Performs one bounded native selection read while teardown ordering is serialized. +/// +/// The raw pointer is borrowed from ``TerminalSurface`` and transported only to the +/// teardown coordinator actor. The request performs no suspension while using it, so +/// a queued native free cannot interleave with the C calls. +struct TerminalSurfaceRuntimeSelectionRequest: @unchecked Sendable { + let surface: ghostty_surface_t + let maxBytes: Int + + func read() -> TerminalSurfaceSelectionRead { + guard maxBytes > 0, let boundedMaxBytes = UInt(exactly: maxBytes) else { + return .unavailable + } + guard ghostty_surface_has_selection(surface) else { + return .none + } + + var selection = ghostty_text_s() + guard ghostty_surface_read_selection_clipboard_text( + surface, + boundedMaxBytes, + &selection + ) else { + return .unavailable + } + defer { ghostty_surface_free_text(surface, &selection) } + + guard let bytes = selection.text, selection.text_len > 0 else { + return .selected(text: "") + } + let byteCount = Int(clamping: selection.text_len) + return .selected(text: String( + decoding: Data(bytes: bytes, count: byteCount), + as: UTF8.self + )) + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Selection.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Selection.swift new file mode 100644 index 00000000000..4c388c48746 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Selection.swift @@ -0,0 +1,8 @@ +extension TerminalSurfaceRuntimeTeardownCoordinator { + /// Executes a native selection read in the teardown-serialized lane. + func readSelection( + _ request: TerminalSurfaceRuntimeSelectionRequest + ) -> TerminalSurfaceSelectionRead { + request.read() + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Selection.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Selection.swift new file mode 100644 index 00000000000..4a632895c7b --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Selection.swift @@ -0,0 +1,22 @@ +extension TerminalSurface { + /// Reads a bounded native selection without exposing a raw Ghostty pointer. + /// + /// - Parameter maxBytes: Maximum UTF-8 bytes Ghostty may materialize. + /// - Returns: ``TerminalSurfaceSelectionRead/none`` when no selection is + /// active, ``TerminalSurfaceSelectionRead/selected(_:)`` for bounded text, + /// or ``TerminalSurfaceSelectionRead/unavailable`` when the runtime cannot + /// complete a bounded read. + @MainActor + public func readSelection(maxBytes: Int) async -> TerminalSurfaceSelectionRead { + guard maxBytes > 0, + let surface = liveSurfaceForGhosttyAccess(reason: "readSelection") else { + return .unavailable + } + return await runtimeTeardown.readSelection( + TerminalSurfaceRuntimeSelectionRequest( + surface: surface, + maxBytes: maxBytes + ) + ) + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceSelectionRead.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceSelectionRead.swift new file mode 100644 index 00000000000..fe1adaf5269 --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceSelectionRead.swift @@ -0,0 +1,11 @@ +/// The bounded result of reading a terminal surface's native selection. +public enum TerminalSurfaceSelectionRead: Equatable, Sendable { + /// No selection is currently active on the live surface. + case none + + /// A selection was captured as bounded UTF-8 text. + case selected(text: String) + + /// The surface could not provide a bounded selection read. + case unavailable +} diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/SurfaceList/WorkspaceSurfaceListModel.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/SurfaceList/WorkspaceSurfaceListModel.swift index b2e9ae6eed8..27e713154da 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/SurfaceList/WorkspaceSurfaceListModel.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/SurfaceList/WorkspaceSurfaceListModel.swift @@ -24,6 +24,16 @@ public import Observation public final class WorkspaceSurfaceListModel { @ObservationIgnored private weak var tree: (any WorkspaceSurfaceTreeReading)? + @ObservationIgnored + private var lastObservedPaneIds: Set<UUID> = [] + @ObservationIgnored + private var lastObservedSurfaceIds: Set<UUID> = [] + @ObservationIgnored + private var lastObservedPanelIds: Set<UUID> = [] + + /// Whether the most recent geometry callback changed pane, surface, or panel membership. + @ObservationIgnored + public private(set) var lastGeometryChangeChangedMembership = false /// Creates a detached model; call ``attach(tree:)`` before any derivation. public init() {} @@ -137,20 +147,40 @@ public final class WorkspaceSurfaceListModel { /// split, close) routes through the workspace's geometry-change handler. A /// pure reorder mutates only bonsplit's internal state, which is not /// observed, so observers would miss it. This bumps `paneLayoutVersion` - /// only when the ordered panel-id sequence actually changed, so divider - /// drags and selection-only events (also routed through that handler) do - /// not fire an app-wide change (legacy gate in + /// only when the ordered panel-id sequence changes, while separately + /// reporting pane-set changes so divider drags and selection-only events do + /// not reopen topology work (legacy gate in /// `Workspace.splitTabBar(_:didChangeGeometry:)`). /// - /// Returns whether the layout version was bumped, for callers that want to - /// observe the decision in tests. + /// Returns whether panel order or pane/surface membership changed, for + /// callers that need to invalidate topology-derived state. @discardableResult public func registerGeometryChange() -> Bool { guard let tree else { return false } let currentOrder = orderedPanelIds - guard currentOrder != tree.lastOrderedPanelIds else { return false } - tree.lastOrderedPanelIds = currentOrder - tree.bumpPaneLayoutVersion() + let currentPaneIds = tree.allPaneIds + let paneMembershipChanged = membershipChanged(currentPaneIds, from: lastObservedPaneIds) + if paneMembershipChanged { lastObservedPaneIds = Set(currentPaneIds) } + let currentSurfaceIds = tree.surfaceIdsInTabOrderAcrossAllPanes + let surfaceMembershipChanged = membershipChanged(currentSurfaceIds, from: lastObservedSurfaceIds) + if surfaceMembershipChanged { lastObservedSurfaceIds = Set(currentSurfaceIds) } + let panelMembershipChanged = membershipChanged(currentOrder, from: lastObservedPanelIds) + if panelMembershipChanged { lastObservedPanelIds = Set(currentOrder) } + lastGeometryChangeChangedMembership = paneMembershipChanged + || surfaceMembershipChanged + || panelMembershipChanged + let panelOrderChanged = currentOrder != tree.lastOrderedPanelIds + guard panelOrderChanged || paneMembershipChanged || surfaceMembershipChanged else { + return false + } + if panelOrderChanged { + tree.lastOrderedPanelIds = currentOrder + tree.bumpPaneLayoutVersion() + } return true } + + private func membershipChanged(_ current: [UUID], from previous: Set<UUID>) -> Bool { + current.count != previous.count || current.contains { !previous.contains($0) } + } } diff --git a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceSurfaceListModelTests.swift b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceSurfaceListModelTests.swift index 460416e76ac..514705738be 100644 --- a/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceSurfaceListModelTests.swift +++ b/Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceSurfaceListModelTests.swift @@ -192,20 +192,58 @@ private final class FakeTree: WorkspaceSurfaceTreeReading { // First call: order changed from empty -> bump. #expect(model.registerGeometryChange() == true) + #expect(model.lastGeometryChangeChangedMembership) #expect(tree.bumpCount == 1) #expect(tree.lastOrderedPanelIds == [p1, p2]) // No change -> no bump. #expect(model.registerGeometryChange() == false) + #expect(!model.lastGeometryChangeChangedMembership) #expect(tree.bumpCount == 1) // Reorder -> bump. tree.panes = [.init(id: pane, surfaceIds: [s2, s1], selectedIndex: 0)] #expect(model.registerGeometryChange() == true) + #expect(!model.lastGeometryChangeChangedMembership) #expect(tree.bumpCount == 2) #expect(tree.lastOrderedPanelIds == [p2, p1]) } + @Test func geometryChangeReportsPaneMembershipWithoutPanelOrderChange() { + let (model, tree) = make() + let s1 = UUID(), s2 = UUID() + let p1 = UUID(), p2 = UUID() + let firstPane = UUID() + tree.panes = [.init(id: firstPane, surfaceIds: [s1, s2], selectedIndex: 0)] + tree.surfaceToPanel = [s1: p1, s2: p2] + tree.registry = [p1, p2] + _ = model.registerGeometryChange() + + tree.panes = [ + .init(id: firstPane, surfaceIds: [s1], selectedIndex: 0), + .init(id: UUID(), surfaceIds: [s2], selectedIndex: 0), + ] + #expect(model.registerGeometryChange()) + #expect(model.lastGeometryChangeChangedMembership) + } + + @Test func geometryChangeReportsSurfaceMembershipWithoutPanelOrderChange() { + let (model, tree) = make() + let firstSurface = UUID() + let firstPanel = UUID() + let pane = UUID() + tree.panes = [.init(id: pane, surfaceIds: [firstSurface], selectedIndex: 0)] + tree.surfaceToPanel = [firstSurface: firstPanel] + tree.registry = [firstPanel] + _ = model.registerGeometryChange() + + let replacementSurface = UUID() + tree.panes = [.init(id: pane, surfaceIds: [replacementSurface], selectedIndex: 0)] + tree.surfaceToPanel = [replacementSurface: firstPanel] + #expect(model.registerGeometryChange() == true) + #expect(model.lastGeometryChangeChangedMembership) + } + @Test func detachedModelReturnsEmptyDefaults() { let model = WorkspaceSurfaceListModel() #expect(model.orderedPanelIds == []) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 3112b3ad041..b20f206fe6c 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1,6 +1,146 @@ { "sourceLanguage": "en", "strings": { + "cli.help.readScreen": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Usage: cmux read-screen [flags]\n\nRead terminal text from a surface as plain text.\n\nFlags:\n --workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID)\n --surface <id|ref|index> Target surface (default: $CMUX_SURFACE_ID)\n --window <id|ref|index> Window context for workspace/surface refs and indexes\n --scrollback Include scrollback (not just visible viewport)\n --lines <n> Limit to the last n lines (implies --scrollback)\n --selection Read only the active selection; cannot be combined with --scrollback or --lines\n\nExample:\n cmux read-screen\n cmux read-screen --surface surface:2 --scrollback --lines 200\n cmux read-screen --surface surface:2 --selection" } }, + "ja": { "stringUnit": { "state": "translated", "value": "使用方法: cmux read-screen [flags]\n\nサーフェスのターミナルテキストをプレーンテキストとして読み取ります。\n\nフラグ:\n --workspace <id|ref|index> 対象ワークスペース(既定: $CMUX_WORKSPACE_ID)\n --surface <id|ref|index> 対象サーフェス(既定: $CMUX_SURFACE_ID)\n --window <id|ref|index> ワークスペース/サーフェスの参照とインデックスに使うウィンドウコンテキスト\n --scrollback 表示中のビューポートだけでなくスクロールバックも含める\n --lines <n> 末尾 n 行に制限する(--scrollback を含意)\n --selection アクティブな選択範囲だけを読み取る(--scrollback、--lines との併用不可)\n\n例:\n cmux read-screen\n cmux read-screen --surface surface:2 --scrollback --lines 200\n cmux read-screen --surface surface:2 --selection" } } + } + }, + "cli.help.readSelection": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Usage: cmux read-selection [flags]\n\nRead the active selection from any selectable surface. Plain output includes source context; --json returns the complete response.\n\nFlags:\n --workspace <id|ref|index> Target workspace (default: $CMUX_WORKSPACE_ID)\n --surface <id|ref|index> Target surface (default: $CMUX_SURFACE_ID)\n --window <id|ref|index> Window context for workspace/surface refs and indexes\n\nExample:\n cmux read-selection --surface surface:2\n cmux read-selection --surface surface:2 --json" } }, + "ja": { "stringUnit": { "state": "translated", "value": "使用方法: cmux read-selection [flags]\n\n選択可能なサーフェスからアクティブな選択範囲を読み取ります。通常出力にはソースコンテキストが含まれ、--json は完全なレスポンスを返します。\n\nフラグ:\n --workspace <id|ref|index> 対象ワークスペース(既定: $CMUX_WORKSPACE_ID)\n --surface <id|ref|index> 対象サーフェス(既定: $CMUX_SURFACE_ID)\n --window <id|ref|index> ワークスペース/サーフェスの参照とインデックスに使うウィンドウコンテキスト\n\n例:\n cmux read-selection --surface surface:2\n cmux read-selection --surface surface:2 --json" } } + } + }, + "cli.readSelection.output.noActiveSelection": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Has selection: false" } }, + "ja": { "stringUnit": { "state": "translated", "value": "選択範囲あり: false" } } + } + }, + "cli.readSelection.error.readScreenConflict": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "read-screen: --selection cannot be combined with --scrollback or --lines" } }, + "ja": { "stringUnit": { "state": "translated", "value": "read-screen: --selection は --scrollback または --lines と併用できません" } } + } + }, + "cli.readSelection.error.unexpectedArguments": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%@: unexpected arguments: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@: 予期しない引数: %@" } } + } + }, + "cli.readSelection.output.file": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "File: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "ファイル: %@" } } + } + }, + "cli.readSelection.output.kind": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Kind: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "種類: %@" } } + } + }, + "cli.readSelection.output.line": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Line: %lld" } }, + "ja": { "stringUnit": { "state": "translated", "value": "行: %lld" } } + } + }, + "cli.readSelection.output.lines": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Lines: %lld-%lld" } }, + "ja": { "stringUnit": { "state": "translated", "value": "行: %lld-%lld" } } + } + }, + "cli.readSelection.output.url": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "URL: %@" } }, + "ja": { "stringUnit": { "state": "translated", "value": "URL: %@" } } + } + }, + "cli.usage.readScreen": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "read-screen [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--scrollback] [--lines <n>] [--selection]" } }, + "ja": { "stringUnit": { "state": "translated", "value": "read-screen [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--scrollback] [--lines <n>] [--selection]" } } + } + }, + "cli.usage.readSelection": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "read-selection [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]" } }, + "ja": { "stringUnit": { "state": "translated", "value": "read-selection [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]" } } + } + }, + "socket.surfaceSelection.noFocusedSurface": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "No surface is focused." } }, + "ja": { "stringUnit": { "state": "translated", "value": "フォーカスされているサーフェスがありません。" } } + } + }, + "socket.surfaceSelection.invalidSelector": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Invalid selector for `%@`." } }, + "ja": { "stringUnit": { "state": "translated", "value": "`%@` のセレクターが無効です。" } } + } + }, + "socket.surfaceSelection.surfaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Surface not found for the given surface selector." } }, + "ja": { "stringUnit": { "state": "translated", "value": "指定したサーフェスセレクターに一致するサーフェスが見つかりません。" } } + } + }, + "socket.surfaceSelection.tabManagerUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Selection reading is currently unavailable." } }, + "ja": { "stringUnit": { "state": "translated", "value": "現在、選択範囲を読み取れません。" } } + } + }, + "socket.surfaceSelection.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Selection reading is currently unavailable." } }, + "ja": { "stringUnit": { "state": "translated", "value": "現在、選択範囲を読み取れません。" } } + } + }, + "socket.surfaceSelection.timeout": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Request timed out after 5 seconds" } }, + "ja": { "stringUnit": { "state": "translated", "value": "リクエストは5秒後にタイムアウトしました。" } } + } + }, + "socket.surfaceSelection.unsupported": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This surface does not support selection reads." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このサーフェスは選択範囲の読み取りに対応していません。" } } + } + }, + "socket.surfaceSelection.workspaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Workspace not found." } }, + "ja": { "stringUnit": { "state": "translated", "value": "ワークスペースが見つかりません。" } } + } + }, "socket.error.unknownMethod": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyTerminalAppearance.swift b/Sources/GhosttyTerminalAppearance.swift index 5f18c85a702..725cbf693da 100644 --- a/Sources/GhosttyTerminalAppearance.swift +++ b/Sources/GhosttyTerminalAppearance.swift @@ -94,6 +94,7 @@ enum GhosttyNotificationKey { static let tabId = "ghostty.tabId" static let surfaceId = "ghostty.surfaceId" static let explicitFocusIntent = "ghostty.explicitFocusIntent" + static let topologyChanged = "workspace.topologyChanged" static let focusTransactionId = "ghostty.focusTransactionId" static let title = "ghostty.title" static let sourceSurfaceIdentifier = "ghostty.sourceSurfaceIdentifier" diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 167d0267ebf..bae13ca3301 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1993,6 +1993,7 @@ final class BrowserPanel: Panel, ObservableObject { /// The underlying web view private(set) var webView: WKWebView + private let surfaceSelectionReader = WebSurfaceSelectionReader() let viewportHostView = BrowserViewportHostView(frame: .zero) let viewportModel = BrowserViewportModel() var browserViewportHostRestorationTask: Task<Void, Never>? @@ -2935,6 +2936,7 @@ final class BrowserPanel: Panel, ObservableObject { // Enable JavaScript configuration.defaultWebpagePreferences.allowsContentJavaScript = true + WebSurfaceSelectionReader.installTracking(in: configuration.userContentController) configuration.userContentController.addUserScript( WKUserScript( source: BrowserFileSystemAccessBridge.scriptSource, @@ -7123,6 +7125,19 @@ extension BrowserPanel { try await webView.evaluateJavaScript(script) } + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + let url = preferredURLStringForOmnibar() + guard hasCommittedDocumentSinceWebViewReplacement || + webView.backForwardList.currentItem != nil else { + return .snapshot(.none(kind: .browser, url: url)) + } + return await surfaceSelectionReader.read( + webView: webView, + kind: .browser, + url: url + ) + } + // MARK: - Find in Page /// Whether the current page is a ready diff viewer app. The diff viewer diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index e31c16cd2cd..ad7bcfa7c51 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1278,6 +1278,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan var isClosed = false weak var textView: NSTextView? let focusCoordinator: FilePreviewFocusCoordinator + private let selectionReader = NativeTextSurfaceSelectionReader() private let textLoader: @Sendable (URL) async -> FilePreviewTextLoader.Result private let textSaver: @Sendable (String, URL, String.Encoding) async -> FilePreviewTextSaver.Result private let modeResolver: @Sendable (URL) async -> FilePreviewMode @@ -1344,11 +1345,21 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan stopWatchingForFileChanges() textLoadCoordinator.cancel() modeLoadCoordinator.cancel() + selectionReader.close() nativeViewSessions.closeAll() textView = nil focusCoordinator.unregisterAll() } + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + guard previewMode == .text else { return .unsupported } + return .snapshot(await selectionReader.read( + textView: textView, + kind: .filePreview, + filePath: filePath + )) + } + /// Retargets container-scoped identity after a live panel transfer. func updateWorkspaceId(_ workspaceId: UUID) { self.workspaceId = workspaceId diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 3f75eed015f..49ccfa84123 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -107,6 +107,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel /// mounted its WKWebView. The renderer fulfills this at window attach. private var pendingPreviewFocus = false private weak var textView: NSTextView? + private let selectionReader = NativeTextSurfaceSelectionReader() private var isClosed: Bool = false // NotificationCenter token; removal is thread-safe so deinit can drop it. private nonisolated(unsafe) var typographyDefaultsObserver: NSObjectProtocol? @@ -414,6 +415,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel pendingPreviewFocus = false searchState = nil rendererSession.close() + selectionReader.close() GlobalSearchCoordinator.shared.purgePanel(id: id) textView = nil stopWatching() @@ -440,6 +442,19 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel } } + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + switch displayMode { + case .text: + return .snapshot(await selectionReader.read( + textView: textView, + kind: .markdown, + filePath: filePath + )) + case .preview: + return await rendererSession.readSurfaceSelection(filePath: filePath) + } + } + /// Re-reads the file without discarding an unsaved TextEdit buffer. func reloadFromDisk() { loadFileContent(replacingDirtyContent: false) diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index 4a5ae9b6e41..fa59369e867 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -58,6 +58,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { let config = WKWebViewConfiguration() config.suppressesIncrementalRendering = false + WebSurfaceSelectionReader.installTracking(in: config.userContentController) // Bridge: JS posts to `cmuxLib` to request lazy-loaded libraries // (mermaid / vega-lite). Swift fetches the bundled source from the // app bundle and injects it via evaluateJavaScript. @@ -151,6 +152,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { @MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, WKURLSchemeHandler { var webView: MarkdownWebView? + private let surfaceSelectionReader = WebSurfaceSelectionReader() /// Fired after each successful markdown render push (initial shell /// load included). Re-rendering replaces the content DOM, so an active /// find-in-page search must re-run to restore its highlights. @@ -357,6 +359,18 @@ struct MarkdownWebRenderer: NSViewRepresentable { return await evaluateString("window.__cmuxRenderedText && window.__cmuxRenderedText()") } + func readSurfaceSelection(filePath: String) async -> SurfaceSelectionReadResult { + let normalizedPath = URL(fileURLWithPath: filePath).standardizedFileURL.path + guard isLoaded, let webView else { + return .snapshot(.none(kind: .markdown, filePath: normalizedPath)) + } + return await surfaceSelectionReader.read( + webView: webView, + kind: .markdown, + filePath: normalizedPath + ) + } + private func evaluateString(_ script: String) async -> String? { guard let webView else { return nil } do { diff --git a/Sources/Panels/MarkdownWebSupport.swift b/Sources/Panels/MarkdownWebSupport.swift index 24d7878b761..da7b53634cb 100644 --- a/Sources/Panels/MarkdownWebSupport.swift +++ b/Sources/Panels/MarkdownWebSupport.swift @@ -462,6 +462,10 @@ final class MarkdownRendererSession { func renderedText() async -> String? { await ownedCoordinator.renderedText() } + + func readSurfaceSelection(filePath: String) async -> SurfaceSelectionReadResult { + await ownedCoordinator.readSurfaceSelection(filePath: filePath) + } } extension NSColor { diff --git a/Sources/Panels/NativeTextSurfaceSelectionReader.swift b/Sources/Panels/NativeTextSurfaceSelectionReader.swift new file mode 100644 index 00000000000..b74a67c0c3a --- /dev/null +++ b/Sources/Panels/NativeTextSurfaceSelectionReader.swift @@ -0,0 +1,319 @@ +import AppKit +import Foundation + +/// Maps an AppKit UTF-16 selection to source text and one-based line numbers. +@MainActor +final class NativeTextSurfaceSelectionReader { + private typealias LineIndex = (starts: [Int], isComplete: Bool) + + private nonisolated static let maximumIndexedSourceUTF16Length = 16 * 1024 * 1024 + private nonisolated static let maximumIndexedLineStarts = 250_000 + + private weak var observedTextStorage: NSTextStorage? + private var editingObserver: NSObjectProtocol? + private var cachedSource: String? + private var cachedSourceUTF16Length: Int? + private var cachedLineIndex: LineIndex? + private var lineIndexTask: Task<LineIndex?, Never>? + private var cacheGeneration = 0 + + deinit { + if let editingObserver { + NotificationCenter.default.removeObserver(editingObserver) + } + } + + /// Stops observation and cancels any pending index construction. + func close() { + if let editingObserver { + NotificationCenter.default.removeObserver(editingObserver) + self.editingObserver = nil + } + lineIndexTask?.cancel() + lineIndexTask = nil + observedTextStorage = nil + invalidateCache() + } + + /// Reads the current selection without copying or scanning the document on every poll. + func read( + textView: NSTextView?, + kind: PanelType, + filePath: String + ) async -> SurfaceSelectionSnapshot { + let normalizedPath = URL(fileURLWithPath: filePath).standardizedFileURL.path + guard let textView, let textStorage = textView.textStorage else { + return .none(kind: kind, filePath: normalizedPath) + } + + observe(textStorage) + for _ in 0..<3 { + let selectedRange = textView.selectedRange() + let sourceLength = textStorage.length + guard selectedRange.location != NSNotFound, + selectedRange.length > 0, + selectedRange.location <= sourceLength, + selectedRange.length <= sourceLength - selectedRange.location else { + return .none(kind: kind, filePath: normalizedPath) + } + + let generation = cacheGeneration + let source: String + if let cachedSource, cachedSourceUTF16Length == sourceLength { + source = cachedSource + } else if sourceLength <= Self.maximumIndexedSourceUTF16Length { + let loadedSource = textStorage.string + source = loadedSource + cachedSource = loadedSource + cachedSourceUTF16Length = sourceLength + } else { + return Self.snapshotFromStorage( + textStorage: textStorage, + selectedRange: selectedRange, + kind: kind, + filePath: normalizedPath + ) + } + + let lineIndex = await lineIndex(for: source, generation: generation) + guard !Task.isCancelled else { + return .none(kind: kind, filePath: normalizedPath) + } + guard generation == cacheGeneration else { continue } + return await Self.makeSnapshot( + source: source, + selectedLocation: selectedRange.location, + selectedLength: selectedRange.length, + lineIndex: lineIndex, + kind: kind, + filePath: normalizedPath + ) + } + + guard !Task.isCancelled else { + return .none(kind: kind, filePath: normalizedPath) + } + let selectedRange = textView.selectedRange() + let sourceLength = textStorage.length + guard selectedRange.location != NSNotFound, + selectedRange.length > 0, + selectedRange.location <= sourceLength, + selectedRange.length <= sourceLength - selectedRange.location else { + return .none(kind: kind, filePath: normalizedPath) + } + return Self.snapshotFromStorage( + textStorage: textStorage, + selectedRange: selectedRange, + kind: kind, + filePath: normalizedPath + ) + } + + private func observe(_ textStorage: NSTextStorage) { + guard observedTextStorage !== textStorage else { return } + if let editingObserver { + NotificationCenter.default.removeObserver(editingObserver) + self.editingObserver = nil + } + invalidateCache() + observedTextStorage = textStorage + editingObserver = NotificationCenter.default.addObserver( + forName: NSTextStorage.willProcessEditingNotification, + object: textStorage, + queue: nil + ) { [weak self] _ in + // Text views in cmux are MainActor-owned; invalidating from the + // synchronous will-process notification closes the same-length + // edit window before the storage contents change. + MainActor.assumeIsolated { + self?.invalidateCache() + } + } + } + + private func invalidateCache() { + cacheGeneration &+= 1 + cachedSource = nil + cachedSourceUTF16Length = nil + cachedLineIndex = nil + lineIndexTask?.cancel() + lineIndexTask = nil + } + + private func lineIndex(for source: String, generation: Int) async -> LineIndex? { + if let cachedLineIndex { + return cachedLineIndex + } + if let lineIndexTask { + // This task is shared by concurrent reads for the same text + // storage. Cache invalidation and close own its cancellation; + // one canceled request must not discard work another request needs. + let result = await Self.awaitSharedLineIndex(lineIndexTask) + if generation == cacheGeneration, let result { + cachedLineIndex = result + } + return result + } + + let task = Task { + await Self.buildLineIndex(source) + } + lineIndexTask = task + let result = await Self.awaitSharedLineIndex(task) + guard generation == cacheGeneration else { return nil } + cachedLineIndex = result + return result + } + + private nonisolated static func awaitSharedLineIndex( + _ task: Task<LineIndex?, Never> + ) async -> LineIndex? { + let (stream, continuation) = AsyncStream<LineIndex?>.makeStream( + bufferingPolicy: .bufferingOldest(1) + ) + let producer = Task { + continuation.yield(await task.value) + continuation.finish() + } + defer { + producer.cancel() + continuation.finish() + } + return await withTaskCancellationHandler(operation: { + var iterator = stream.makeAsyncIterator() + return (await iterator.next()) ?? nil + }, onCancel: { + // Finish this waiter without canceling the shared index task. + producer.cancel() + continuation.finish() + }) + } + + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + private nonisolated static func buildLineIndex(_ source: String) async -> LineIndex? { + var starts = [0] + starts.reserveCapacity(min(maximumIndexedLineStarts, 16_384)) + var offset = 0 + var previousWasCarriageReturn = false + for unit in source.utf16 { + if Task.isCancelled { return nil } + switch unit { + case 0x0D: // CR; CRLF is corrected when the following LF arrives. + guard starts.count < maximumIndexedLineStarts else { + return (starts: starts, isComplete: false) + } + starts.append(offset + 1) + previousWasCarriageReturn = true + case 0x0A: // LF, including the second half of CRLF. + if previousWasCarriageReturn { + starts[starts.count - 1] = offset + 1 + } else { + guard starts.count < maximumIndexedLineStarts else { + return (starts: starts, isComplete: false) + } + starts.append(offset + 1) + } + previousWasCarriageReturn = false + case 0x85, 0x2028, 0x2029: // NEL, line separator, paragraph separator. + guard starts.count < maximumIndexedLineStarts else { + return (starts: starts, isComplete: false) + } + starts.append(offset + 1) + previousWasCarriageReturn = false + default: + previousWasCarriageReturn = false + } + offset += 1 + if offset & 0x0FFF == 0, Task.isCancelled { return nil } + } + return (starts: starts, isComplete: true) + } + + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + private nonisolated static func makeSnapshot( + source: String, + selectedLocation: Int, + selectedLength: Int, + lineIndex: LineIndex?, + kind: PanelType, + filePath: String + ) async -> SurfaceSelectionSnapshot { + guard !Task.isCancelled else { + return .none(kind: kind, filePath: filePath) + } + let boundedLength = min(selectedLength, SurfaceSelectionSnapshot.maximumTextBytes) + let selectedText = substring( + source, + location: selectedLocation, + length: boundedLength + ) + let lineRange = lineIndex.flatMap { index -> SurfaceSelectionLineRange? in + guard index.isComplete, + let start = lineNumber(atUTF16Offset: selectedLocation, in: index), + let end = lineNumber( + atUTF16Offset: selectedLocation + selectedLength - 1, + in: index + ) else { + return nil + } + return SurfaceSelectionLineRange(start: start, end: end) + } + guard !Task.isCancelled else { + return .none(kind: kind, filePath: filePath) + } + return .selected( + kind: kind, + text: SurfaceSelectionSnapshot.boundedText(selectedText), + filePath: filePath, + lineRange: lineRange + ) + } + + private static func snapshotFromStorage( + textStorage: NSTextStorage, + selectedRange: NSRange, + kind: PanelType, + filePath: String + ) -> SurfaceSelectionSnapshot { + let boundedRange = NSRange( + location: selectedRange.location, + length: min(selectedRange.length, SurfaceSelectionSnapshot.maximumTextBytes) + ) + let selectedText = textStorage.attributedSubstring(from: boundedRange).string + return .selected( + kind: kind, + text: SurfaceSelectionSnapshot.boundedText(selectedText), + filePath: filePath + ) + } + + private nonisolated static func substring(_ source: String, location: Int, length: Int) -> String { + let utf16 = source.utf16 + let start = String.Index(utf16Offset: location, in: source) + let end = String.Index(utf16Offset: location + length, in: source) + return String(decoding: utf16[start..<end], as: UTF16.self) + } + + private nonisolated static func lineNumber(atUTF16Offset offset: Int, in index: LineIndex) -> Int? { + guard index.isComplete else { return nil } + var low = 0 + var high = index.starts.count + while low < high { + let middle = (low + high) / 2 + if index.starts[middle] <= offset { + low = middle + 1 + } else { + high = middle + } + } + return max(1, low) + } +} diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index ecf410db02c..5eab3ef16f6 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -330,6 +330,9 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI /// Unfocus the panel func unfocus() + /// Read the panel's live user selection without changing focus or UI state. + func readSurfaceSelection() async -> SurfaceSelectionReadResult + /// Trigger a focus flash animation for this panel. func triggerFlash(reason: WorkspaceAttentionFlashReason) @@ -359,6 +362,11 @@ extension Panel { public var displayIcon: String? { nil } public var isDirty: Bool { false } + /// Captures the panel's current selection without changing focus or state. + public func readSurfaceSelection() async -> SurfaceSelectionReadResult { + .unsupported + } + func captureFocusIntent(in window: NSWindow?) -> PanelFocusIntent { _ = window return preferredFocusIntentForActivation() diff --git a/Sources/Panels/SurfaceSelectionLineRange.swift b/Sources/Panels/SurfaceSelectionLineRange.swift new file mode 100644 index 00000000000..ccc3c4472e7 --- /dev/null +++ b/Sources/Panels/SurfaceSelectionLineRange.swift @@ -0,0 +1,21 @@ +import Foundation + +/// One-based inclusive line range for selected native text. +public nonisolated struct SurfaceSelectionLineRange: Equatable, Sendable { + /// First selected source line, using one-based indexing. + public let start: Int + + /// Last selected source line, using one-based inclusive indexing. + public let end: Int + + /// Creates a valid one-based inclusive source range. + /// + /// - Parameters: + /// - start: First selected line. Must be at least `1`. + /// - end: Last selected line. Must not precede `start`. + public init?(start: Int, end: Int) { + guard start >= 1, end >= start else { return nil } + self.start = start + self.end = end + } +} diff --git a/Sources/Panels/SurfaceSelectionReadResult.swift b/Sources/Panels/SurfaceSelectionReadResult.swift new file mode 100644 index 00000000000..5df1cf02d0e --- /dev/null +++ b/Sources/Panels/SurfaceSelectionReadResult.swift @@ -0,0 +1,13 @@ +import Foundation + +/// A panel's supported, unsupported, or temporarily unavailable selection state. +public nonisolated enum SurfaceSelectionReadResult: Equatable, Sendable { + /// A successfully captured selection snapshot, including an empty state. + case snapshot(SurfaceSelectionSnapshot) + + /// A panel kind that has no meaningful text-selection capability. + case unsupported + + /// A supported panel whose live backing view could not be read. + case unavailable +} diff --git a/Sources/Panels/SurfaceSelectionSnapshot.swift b/Sources/Panels/SurfaceSelectionSnapshot.swift new file mode 100644 index 00000000000..ebc23b2f225 --- /dev/null +++ b/Sources/Panels/SurfaceSelectionSnapshot.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Immutable selection context shared by every selectable panel kind. +public nonisolated struct SurfaceSelectionSnapshot: Equatable, Sendable { + /// Maximum UTF-8 bytes exposed by one selection snapshot. + public static let maximumTextBytes = 1_048_576 + + private let selectedText: String? + + /// Whether the surface currently has a non-collapsed selection. + public var hasSelection: Bool { selectedText != nil } + + /// Panel kind that produced this snapshot. + public let kind: PanelType + + /// Selected text, or an empty string when there is no selection. + public var text: String { selectedText ?? "" } + + /// Truncates text to the shared wire-safe selection budget and appends a + /// visible ellipsis when truncation was required. + public static func boundedText(_ text: String) -> String { + guard text.utf8.count > maximumTextBytes else { return text } + let marker = "…" + let budget = max(0, maximumTextBytes - marker.utf8.count) + var used = 0 + let prefix = text.unicodeScalars.prefix(while: { scalar in + let scalarBytes = scalar.utf8.count + guard used + scalarBytes <= budget else { return false } + used += scalarBytes + return true + }) + return String(prefix) + marker + } + + /// Standardized source path when the selection belongs to a document. + public let filePath: String? + + /// One-based inclusive source lines when native text can map the range. + public let lineRange: SurfaceSelectionLineRange? + + /// Visible page URL when the selection belongs to a browser surface. + public let url: String? + + private init( + selectedText: String?, + kind: PanelType, + filePath: String?, + lineRange: SurfaceSelectionLineRange?, + url: String? + ) { + self.selectedText = selectedText + self.kind = kind + self.filePath = filePath + self.lineRange = lineRange + self.url = url + } + + /// Creates a snapshot containing a live selection. + /// + /// - Parameters: + /// - kind: Panel kind that owns the selection. + /// - text: Selected text. An empty string still represents a live, + /// non-collapsed selection whose DOM range has no textual content. + /// - filePath: Optional standardized source path. + /// - lineRange: Optional validated source line range. + /// - url: Optional visible page URL. + public static func selected( + kind: PanelType, + text: String, + filePath: String? = nil, + lineRange: SurfaceSelectionLineRange? = nil, + url: String? = nil + ) -> Self { + Self( + selectedText: text, + kind: kind, + filePath: filePath, + lineRange: lineRange, + url: url + ) + } + + /// Creates a snapshot with no live selection and no selection-only fields. + /// + /// - Parameters: + /// - kind: Panel kind that was inspected. + /// - filePath: Optional standardized source path. + /// - url: Optional visible page URL. + public static func none( + kind: PanelType, + filePath: String? = nil, + url: String? = nil + ) -> Self { + Self( + selectedText: nil, + kind: kind, + filePath: filePath, + lineRange: nil, + url: url + ) + } +} diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 2589451a727..dddb4441015 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -5,6 +5,7 @@ import AppKit import Bonsplit import CmuxTerminal import CmuxWorkspaces +import GhosttyKit /// TerminalPanel wraps an existing TerminalSurface and conforms to the Panel protocol. /// This allows TerminalSurface to be used within the bonsplit-based layout system. @@ -113,6 +114,22 @@ final class TerminalPanel: Panel, ObservableObject { "terminal.fill" } + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + switch await surface.readSelection( + maxBytes: SurfaceSelectionSnapshot.maximumTextBytes + ) { + case .none: + return .snapshot(.none(kind: .terminal)) + case .selected(let text): + return .snapshot(.selected( + kind: .terminal, + text: SurfaceSelectionSnapshot.boundedText(text) + )) + case .unavailable: + return .unavailable + } + } + func updateShellActivityState(_ state: PanelShellActivityState) { if shellActivity.state != state { shellActivity.state = state diff --git a/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift b/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift new file mode 100644 index 00000000000..3286dde42b2 --- /dev/null +++ b/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift @@ -0,0 +1,64 @@ +import WebKit + +/// Owns at most one in-flight WebKit selection evaluation for a panel. +@MainActor +final class WebSurfaceSelectionEvaluationOwner { + private var activeID: UUID? + private var activeContinuation: CheckedContinuation<String?, Never>? + + /// Starts a page-world evaluation, cancelling and completing any older one. + /// + /// WebKit does not expose a cancellable evaluation handle. Keeping the + /// continuation and request identity here lets socket cancellation resume + /// the caller immediately while ignoring a late WebKit callback; the + /// in-page Promise timeout bounds a responsive WebContent process and the + /// single active continuation prevents request fan-out when it is wedged. + func evaluate(webView: WKWebView, script: String) async -> String? { + finishActive(with: nil) + let requestID = UUID() + return await withTaskCancellationHandler(operation: { + await withCheckedContinuation { (continuation: CheckedContinuation<String?, Never>) in + guard !Task.isCancelled else { + continuation.resume(returning: nil) + return + } + activeID = requestID + activeContinuation = continuation + webView.callAsyncJavaScript( + script, + arguments: [:], + in: nil, + in: .page + ) { [weak self] result in + let value: String? + switch result { + case .success(let raw): + value = raw as? String + case .failure: + value = nil + } + Task { @MainActor in + self?.finish(requestID: requestID, value: value) + } + } + } + }, onCancel: { + Task { @MainActor [weak self] in + self?.finish(requestID: requestID, value: nil) + } + }) + } + + private func finish(requestID: UUID, value: String?) { + guard activeID == requestID else { return } + activeID = nil + let continuation = activeContinuation + activeContinuation = nil + continuation?.resume(returning: value) + } + + private func finishActive(with value: String?) { + guard let activeID else { return } + finish(requestID: activeID, value: value) + } +} diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift new file mode 100644 index 00000000000..2fc4efb0efe --- /dev/null +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -0,0 +1,503 @@ +import Foundation +import WebKit + +/// Reads DOM or editable-control selection without changing focus or page state. +@MainActor +final class WebSurfaceSelectionReader { + private let evaluationOwner = WebSurfaceSelectionEvaluationOwner() + + private nonisolated struct Payload: Decodable { + let hasSelection: Bool + let text: String + let blocksFallback: Bool + + enum CodingKeys: String, CodingKey { + case hasSelection = "has_selection" + case text + case blocksFallback = "blocks_fallback" + } + } + + private static let trackingBootstrapScript = """ + (() => { + const runtimeKey = '__cmuxSurfaceSelectionRuntime'; + // A pre-existing page-owned value is never trusted. The first + // at-document-start install wins; subsequent installs fail closed. + if (Object.prototype.hasOwnProperty.call(globalThis, runtimeKey)) return false; + + const maxTextCharacters = \(SurfaceSelectionSnapshot.maximumTextBytes / 4); + const maxTraversalNodes = 4096; + const unicodeSafeEnd = (value, offset) => { + let end = Math.max(0, Math.min(value.length, offset)); + if (end > 0 && end < value.length) { + const previous = value.charCodeAt(end - 1); + const next = value.charCodeAt(end); + if (previous >= 0xD800 && previous <= 0xDBFF && + next >= 0xDC00 && next <= 0xDFFF) { + end -= 1; + } + } + return end; + }; + const boundedText = (text) => { + const value = String(text || ''); + if (value.length <= maxTextCharacters) return value; + return value.slice(0, unicodeSafeEnd(value, maxTextCharacters - 1)) + '…'; + }; + const boundedControlText = (value, start, end) => { + const source = String(value || ''); + const safeStart = Math.max(0, Math.min(source.length, Number(start) || 0)); + const safeEnd = Math.max(safeStart, Math.min(source.length, Number(end) || 0)); + const length = safeEnd - safeStart; + if (length <= maxTextCharacters) return source.slice(safeStart, safeEnd); + const boundedEnd = unicodeSafeEnd(source, safeStart + maxTextCharacters - 1); + return source.slice(safeStart, boundedEnd) + '…'; + }; + // Range#toString materializes the complete DOM selection. Walk text + // nodes instead and stop after the wire budget, so large selections do + // not allocate an unbounded string on every selectionchange/read. + const boundedRangeText = (range) => { + try { + const root = range.commonAncestorContainer; + const ownerDocument = root.ownerDocument || root; + const walker = ownerDocument.createTreeWalker(root, 4); + const boundedRange = range.cloneRange(); + let used = 0; + let visitedNodes = 0; + let truncated = false; + let node = root.nodeType === 3 ? root : walker.nextNode(); + while (node) { + if (++visitedNodes > maxTraversalNodes) return null; + const value = node.nodeValue || ''; + let start = 0; + let end = value.length; + if (range.comparePoint(node, end) < 0) { + node = walker.nextNode(); + continue; + } + if (range.comparePoint(node, 0) > 0) break; + if (node === range.startContainer) { + start = Math.max(0, Math.min(end, range.startOffset)); + } + if (node === range.endContainer) { + end = Math.max(start, Math.min(end, range.endOffset)); + } + if (end > start) { + // Reserve one character for the visible truncation marker. + const remaining = maxTextCharacters - 1 - used; + if (remaining <= 0) { + boundedRange.setEnd(node, start); + truncated = true; + break; + } + const count = Math.min(remaining, end - start); + used += count; + if (count < end - start) { + boundedRange.setEnd(node, unicodeSafeEnd(value, start + count)); + truncated = true; + break; + } + } + if (node === range.endContainer) break; + node = walker.nextNode(); + } + const text = boundedText(boundedRange.toString()); + if (!truncated) return text; + const prefix = text.endsWith('…') ? text.slice(0, -1) : text; + return boundedText(prefix + '…'); + } catch (_) { + return null; + } + }; + const empty = () => Object.freeze({ has_selection: false, text: '' }); + const unreadable = (sourceDocument = null, sourceFrame = null) => Object.freeze({ + has_selection: false, + text: '', + blocks_fallback: true, source_document: sourceDocument, source_frame: sourceFrame + }); + const privacyBlocked = () => Object.freeze({ + has_selection: false, + text: '', + privacy_blocked: true + }); + const selected = (text, sourceDocument = null, metadata = {}) => Object.freeze({ + has_selection: true, + text: boundedText(text), + source_document: sourceDocument, + ...metadata + }); + let installDocument; + const deepestActiveElement = (targetDocument) => { + let active = targetDocument.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active; + }; + const readLiveSelection = (targetWindow, fallbackDocument = null, fallbackFrame = null) => { + let targetDocument; + try { + targetDocument = targetWindow.document; + } catch (_) { + return unreadable(fallbackDocument, fallbackFrame); + } + + const active = deepestActiveElement(targetDocument); + const activeTag = String(active?.tagName || '').toLowerCase(); + if (activeTag === 'iframe' || activeTag === 'frame') { + try { + const childWindow = active.contentWindow; + if (!childWindow) return unreadable(targetDocument, active); + try { + installDocument?.(childWindow.document); + } catch (_) {} + return readLiveSelection(childWindow, targetDocument, active); + } catch (_) { + return unreadable(targetDocument, active); + } + } + + const isInput = activeTag === 'input'; + const isTextControl = isInput || activeTag === 'textarea'; + const isPassword = isInput && String(active.type || '').toLowerCase() === 'password'; + if (isPassword) return privacyBlocked(); + if (isTextControl) { + if (typeof active.selectionStart === 'number' && + typeof active.selectionEnd === 'number' && + active.selectionEnd > active.selectionStart) { + return selected( + boundedControlText(active.value, active.selectionStart, active.selectionEnd), + targetDocument, + { + selection_control: active, + selection_start: active.selectionStart, + selection_end: active.selectionEnd + } + ); + } + return empty(); + } + + const selection = targetWindow.getSelection(); + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + try { + const range = selection.getRangeAt(0).cloneRange(); + const text = boundedRangeText(range); + return text === null + ? unreadable(targetDocument) + : selected(text, targetDocument, { selection_range: range }); + } catch (_) { + return unreadable(targetDocument); + } + } + return empty(); + }; + + const locationForDocument = (sourceDocument) => { + try { + const href = sourceDocument?.location?.href; + return typeof href === 'string' && href.length > 0 ? href : null; + } catch (_) { + return null; + } + }; + let retainedSelection = empty(); + let retainedDocument = null; + let retainedFrame = null; + let retainedLocation = null; + let retainedRange = null; + let retainedControl = null; + let retainedControlStart = null; + let retainedControlEnd = null; + const clear = (sourceDocument = null) => { + if (sourceDocument && retainedDocument && retainedDocument !== sourceDocument) return; + retainedSelection = empty(); + retainedDocument = null; + retainedLocation = null; + retainedRange = null; + retainedControl = null; + retainedControlStart = null; retainedControlEnd = null; retainedFrame = null; + }; + const retain = (live) => { + retainedSelection = selected(live.text); + retainedDocument = live.source_document || null; + retainedLocation = locationForDocument(retainedDocument); + retainedRange = live.selection_range || null; + retainedControl = live.selection_control || null; + retainedControlStart = live.selection_start ?? null; + retainedControlEnd = live.selection_end ?? null; retainedFrame = null; + }; + const retainUnreadable = (live) => { + retainedSelection = unreadable(live.source_document || null, live.source_frame || null); + retainedDocument = live.source_document || null; + retainedFrame = live.source_frame || null; + retainedLocation = locationForDocument(retainedDocument); + retainedRange = null; + retainedControl = null; + retainedControlStart = null; + retainedControlEnd = null; + }; + const capture = (targetWindow, clearWhenEmpty = false) => { + const live = readLiveSelection(targetWindow); + if (live.blocks_fallback) { + retainUnreadable(live); + } else if (live.privacy_blocked) { + clear(); + } else if (live.has_selection) { + retain(live); + } else if (clearWhenEmpty) { + clear(); + } + }; + // Socket reads are observers. Re-querying WebKit here would create a + // second mutation path that can erase the event-owned snapshot after + // native focus moves to a neighboring surface. + const retainedContentStillValid = () => { + if (retainedRange) { + try { + if (retainedRange.startContainer?.isConnected === false || + retainedRange.endContainer?.isConnected === false) { + return false; + } + const text = boundedRangeText(retainedRange); + return text !== null && text === retainedSelection.text; + } catch (_) { + return false; + } + } + if (retainedControl) { + try { + if (retainedControl.isConnected === false) return false; + const start = Number(retainedControlStart); + const end = Number(retainedControlEnd); + return boundedControlText(retainedControl.value, start, end) === retainedSelection.text; + } catch (_) { + return false; + } + } + return true; + }; + const clearIfDetachedFrame = () => { + if (retainedFrame && !retainedFrame.isConnected) { clear(); return; } + if (!retainedDocument || retainedDocument === document) return; + try { + const frame = retainedDocument.defaultView?.frameElement; + if (!frame || !frame.isConnected) clear(); + } catch (_) { + clear(); + } + }; + + const read = () => { + clearIfDetachedFrame(); + if (retainedDocument && !retainedContentStillValid()) { + clear(retainedDocument); + } + if (retainedDocument && retainedLocation !== null) { + const currentLocation = locationForDocument(retainedDocument); + if (currentLocation !== null && currentLocation !== retainedLocation) { + clear(retainedDocument); + } + } + return retainedSelection; + }; + + const trackedDocuments = new WeakSet(); + const selectionChangingKeys = new Set([ + 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', + 'Backspace', 'Delete', 'End', 'Enter', 'Escape', + 'Home', 'PageDown', 'PageUp', 'Tab' + ]); + const keyChangesSelection = (event) => { + const key = String(event?.key || ''); + if (selectionChangingKeys.has(key)) return true; + return key.length === 1 && !event?.metaKey && !event?.ctrlKey; + }; + const installFrame = (frame) => { + try { + if (frame?.contentDocument) installDocument(frame.contentDocument); + } catch (_) {} + }; + const scanFrames = (root) => { + try { + const rootTag = String(root?.tagName || '').toLowerCase(); + if (rootTag === 'iframe' || rootTag === 'frame') installFrame(root); + const frames = root?.querySelectorAll?.('iframe, frame') || []; + for (const frame of frames) installFrame(frame); + } catch (_) {} + }; + installDocument = (targetDocument) => { + if (!targetDocument || trackedDocuments.has(targetDocument)) return; + trackedDocuments.add(targetDocument); + let captureQueued = false; + const captureDocument = () => { + if (captureQueued) return; + captureQueued = true; + queueMicrotask(() => { + captureQueued = false; + const targetWindow = targetDocument.defaultView; + if (!targetWindow) return; + // `selectionchange` is observational. WebKit emits a collapsed + // event before `blur` when native focus moves to another cmux + // surface, so consulting `document.hasFocus()` here can erase a + // valid snapshot before the focus handoff has settled. Concrete + // page interactions (pointer/keyboard/select) and input events + // own clearing instead; a later non-empty event replaces the + // retained immutable snapshot. + capture(targetWindow); + }); + }; + const reconcileInput = () => { + const targetWindow = targetDocument.defaultView; + if (targetWindow) capture(targetWindow, true); + }; + const clearForInteraction = () => clear(); + // A collapsed selectionchange is not itself a clear signal: WebKit + // also emits one when native focus moves to a neighboring surface. + // Concrete page interaction owns clearing; a later non-empty change + // replaces the retained immutable snapshot. + targetDocument.addEventListener('selectionchange', captureDocument, true); + targetDocument.addEventListener('select', captureDocument, true); + targetDocument.addEventListener('selectstart', clearForInteraction, true); + targetDocument.addEventListener('pointerdown', clearForInteraction, true); + targetDocument.addEventListener('mousedown', clearForInteraction, true); + targetDocument.addEventListener('keydown', (event) => { + if (keyChangesSelection(event)) clear(); + }, true); + targetDocument.addEventListener('focusin', captureDocument, true); + targetDocument.addEventListener('input', reconcileInput, true); + const invalidateForNavigation = () => clear(targetDocument); + targetDocument.defaultView?.addEventListener('hashchange', invalidateForNavigation, true); + targetDocument.defaultView?.addEventListener('popstate', invalidateForNavigation, true); + targetDocument.defaultView?.addEventListener('pagehide', invalidateForNavigation, true); + targetDocument.addEventListener('load', (event) => { + const target = event?.target; + const tag = String(target?.tagName || '').toLowerCase(); + if (tag === 'iframe' || tag === 'frame') { + installFrame(target); + captureDocument(); + } + }, true); + targetDocument.addEventListener('DOMContentLoaded', () => { + scanFrames(targetDocument); + captureDocument(); + }, { once: true }); + // The initial scan and capture-phase load listener discover frames at + // their lifecycle boundary. Selection reads validate retained ranges + // lazily, so a document-wide mutation observer is unnecessary work. + scanFrames(targetDocument); + const targetWindow = targetDocument.defaultView; + targetWindow?.addEventListener('beforeunload', () => { + if (targetDocument === document) { + clear(); + } else { + clear(targetDocument); + } + }, true); + }; + + // Keep the page-world bridge immutable. Site JavaScript shares this + // world, so a writable global would let a page replace `read` and + // bypass the password-control guard before the app evaluates it. + const runtime = Object.freeze({ read }); + Object.defineProperty(globalThis, runtimeKey, { + configurable: false, + enumerable: false, + value: runtime, + writable: false + }); + installDocument(document); + capture(window); + return true; + })() + """ + + private static let evaluationTimeoutMilliseconds = 4_000 + private static let script = """ + return await (async () => { + const readSelection = () => { + const runtime = globalThis.__cmuxSurfaceSelectionRuntime; + if (!runtime || typeof runtime.read !== 'function') return null; + const maxTextCharacters = \(SurfaceSelectionSnapshot.maximumTextBytes / 4); + const unicodeSafeEnd = (value, offset) => { + let end = Math.max(0, Math.min(value.length, offset)); + if (end > 0 && end < value.length) { + const previous = value.charCodeAt(end - 1); + const next = value.charCodeAt(end); + if (previous >= 0xD800 && previous <= 0xDBFF && + next >= 0xDC00 && next <= 0xDFFF) { + end -= 1; + } + } + return end; + }; + const boundedText = (text) => { + const value = String(text || ''); + if (value.length <= maxTextCharacters) return value; + return value.slice(0, unicodeSafeEnd(value, maxTextCharacters - 1)) + '…'; + }; + const result = runtime.read(); + return JSON.stringify({ + has_selection: result?.has_selection === true, + text: result?.has_selection === true ? boundedText(result.text) : '', + blocks_fallback: result?.blocks_fallback === true + }); + }; + return await Promise.race([ + Promise.resolve().then(readSelection), + new Promise((resolve) => setTimeout(() => resolve(null), \(WebSurfaceSelectionReader.evaluationTimeoutMilliseconds))) + ]); + })() + """ + + /// Installs the event-owned snapshot in the page world because WebKit does + /// not project its live `Selection` object into isolated content worlds. + @MainActor + static func installTracking(in userContentController: WKUserContentController) { + userContentController.addUserScript(WKUserScript( + source: trackingBootstrapScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: true, + in: .page + )) + } + + @MainActor + func read( + webView: WKWebView, + kind: PanelType, + filePath: String? = nil, + url: String? = nil + ) async -> SurfaceSelectionReadResult { + guard let encoded = await evaluationOwner.evaluate( + webView: webView, + script: Self.script + ), let data = encoded.data(using: .utf8) else { + return .unavailable + } + do { + let payload = try JSONDecoder().decode(Payload.self, from: data) + let normalizedPath = filePath.map { + URL(fileURLWithPath: $0).standardizedFileURL.path + } + if payload.blocksFallback { + return .unavailable + } + if payload.hasSelection { + return .snapshot(.selected( + kind: kind, + text: SurfaceSelectionSnapshot.boundedText(payload.text), + filePath: normalizedPath, + url: url + )) + } + return .snapshot(.none( + kind: kind, + filePath: normalizedPath, + url: url + )) + } catch { + return .unavailable + } + } +} diff --git a/Sources/SurfaceSelectionSocketCapture.swift b/Sources/SurfaceSelectionSocketCapture.swift new file mode 100644 index 00000000000..be55b31ae2d --- /dev/null +++ b/Sources/SurfaceSelectionSocketCapture.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Socket identity paired with an immutable panel selection snapshot. +nonisolated struct SurfaceSelectionSocketCapture: Sendable { + let snapshot: SurfaceSelectionSnapshot + let workspaceID: UUID + let surfaceID: UUID + let windowID: UUID? + let workspaceRef: String + let surfaceRef: String + let windowRef: String? +} diff --git a/Sources/SurfaceSelectionSocketCaptureOutcome.swift b/Sources/SurfaceSelectionSocketCaptureOutcome.swift new file mode 100644 index 00000000000..6b9b2aa2067 --- /dev/null +++ b/Sources/SurfaceSelectionSocketCaptureOutcome.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Sendable boundary between main-actor panel capture and worker-lane encoding. +nonisolated enum SurfaceSelectionSocketCaptureOutcome: Sendable { + case captured(SurfaceSelectionSocketCapture) + case failed(SurfaceSelectionSocketFailure) +} diff --git a/Sources/SurfaceSelectionSocketFailure.swift b/Sources/SurfaceSelectionSocketFailure.swift new file mode 100644 index 00000000000..0a3cc4d3511 --- /dev/null +++ b/Sources/SurfaceSelectionSocketFailure.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Immutable socket error captured while resolving or reading a surface selection. +nonisolated struct SurfaceSelectionSocketFailure: Sendable { + let code: String + let message: String + let data: [String: String]? +} diff --git a/Sources/TerminalController+ControlReadSnapshots.swift b/Sources/TerminalController+ControlReadSnapshots.swift index 3a684b502a0..fc3ea308f47 100644 --- a/Sources/TerminalController+ControlReadSnapshots.swift +++ b/Sources/TerminalController+ControlReadSnapshots.swift @@ -18,6 +18,11 @@ extension TerminalController { } } + /// Reopens opaque-handle discovery after a topology notification. + func invalidateSocketHandleTopologyRefresh() { + controlCommandCoordinator.invalidateHandleTopologyRefresh() + } + private func publishSocketReadSnapshot() { let requests: [ControlRequest] = [ ControlRequest(id: nil, method: "window.list", params: [:]), @@ -46,5 +51,6 @@ extension TerminalController { socketReadSnapshotStore.publish( ControlReadSnapshot(generation: nextGeneration, responses: responses) ) + controlCommandCoordinator.markHandleTopologyRefreshCompleted() } } diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index afe6fef2d60..6857b295660 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -109,6 +109,10 @@ extension TerminalController { private nonisolated func socketWorkerV2ResponseAsync( _ request: ControlRequest ) async -> String? { + if request.method == "surface.read_selection" { + return await socketSurfaceSelectionResponseAsync(request) + } + if request.method == "feed.jump" { guard let result = await controlCommandCoordinator .handleSocketWorkerFeedAsync(request, context: self) else { @@ -201,6 +205,65 @@ extension TerminalController { return socketWorkerV2Response(handling: request) } + /// Runs the live selection read without parking the cooperative executor. + /// Synchronous in-process callers keep the legacy adapter, but socket + /// connections race the read against a cancellable request deadline. + private nonisolated func socketSurfaceSelectionResponseAsync( + _ request: ControlRequest + ) async -> String { + let (responses, continuation) = AsyncStream<String>.makeStream( + bufferingPolicy: .bufferingOldest(1) + ) + let operation = Task { + let result = await self.v2SurfaceReadSelection(params: request.params) + continuation.yield(self.v2Result(id: request.id?.foundationObject, result)) + continuation.finish() + } + let deadlineClock = ContinuousClock() + let timeout = Task { + do { + // Genuine request deadline; cancellation tears down the sleeper. + try await deadlineClock.sleep(for: .seconds(5)) + } catch { + return + } + guard !Task.isCancelled else { return } + continuation.yield(self.v2Error( + id: request.id?.foundationObject, + code: "timeout", + message: String( + localized: "socket.surfaceSelection.timeout", + defaultValue: "Request timed out after 5 seconds" + ) + )) + continuation.finish() + } + continuation.onTermination = { @Sendable _ in + operation.cancel() + timeout.cancel() + } + + let response = await withTaskCancellationHandler( + operation: { + var iterator = responses.makeAsyncIterator() + return await iterator.next() + }, + onCancel: { + operation.cancel() + timeout.cancel() + continuation.finish() + } + ) + operation.cancel() + timeout.cancel() + continuation.finish() + return response ?? v2Error( + id: request.id?.foundationObject, + code: "request_error", + message: "Request failed before returning a result" + ) + } + private nonisolated func v2SystemTopAsync(_ request: ControlRequest) async -> String { let base = await v2MainAsync { let foundationParams = request.params.mapValues(\.foundationObject) diff --git a/Sources/TerminalController+ControlSurfaceDock.swift b/Sources/TerminalController+ControlSurfaceDock.swift index a8aa7ddb7c9..45e05a2eb80 100644 --- a/Sources/TerminalController+ControlSurfaceDock.swift +++ b/Sources/TerminalController+ControlSurfaceDock.swift @@ -456,6 +456,16 @@ extension TerminalController { if let routedSurfaceID = routing.surfaceID { return (routedSurfaceID, false) } + if let routedPaneID = routing.paneID { + guard let paneID = dock.bonsplitController.allPaneIds.first(where: { + $0.id == routedPaneID + }), + let tabID = dock.bonsplitController.selectedTab(inPane: paneID)?.id, + let panel = dock.panel(for: tabID) else { + return (nil, false) + } + return (panel.id, false) + } return (dock.focusedPanelId, false) } diff --git a/Sources/TerminalController+RemoteRelayAuthorization.swift b/Sources/TerminalController+RemoteRelayAuthorization.swift index 4af78e45adf..e1299db4bfb 100644 --- a/Sources/TerminalController+RemoteRelayAuthorization.swift +++ b/Sources/TerminalController+RemoteRelayAuthorization.swift @@ -28,6 +28,7 @@ extension TerminalController { "surface.list", "surface.current", "surface.read_text", + "surface.read_selection", "surface.resume.set", "surface.resume.get", "surface.resume.clear", @@ -72,6 +73,7 @@ extension TerminalController { "surface.resume.get", "surface.resume.clear", "surface.read_text", + "surface.read_selection", "notification.create_for_target", ] diff --git a/Sources/TerminalController+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift new file mode 100644 index 00000000000..03845d1c3e0 --- /dev/null +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -0,0 +1,365 @@ +import CmuxControlSocket +import Foundation + +extension TerminalController { + /// Routes one live selection read through the panel abstraction. AppKit, + /// Ghostty, and WebKit stay on the main actor; only the immutable capture + /// crosses back to the socket worker for payload construction and encoding. + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + nonisolated func v2SurfaceReadSelection( + params: [String: JSONValue] + ) async -> V2CallResult { + let outcome = await captureSurfaceSelection(params: params) + switch outcome { + case .failed(let failure): + return .err( + code: failure.code, + message: failure.message, + data: failure.data + ) + case .captured(let capture): + let selection = capture.snapshot + var payload: [String: Any] = [ + "has_selection": selection.hasSelection, + "kind": selection.kind.rawValue, + "text": selection.text, + "base64": Data(selection.text.utf8).base64EncodedString(), + "workspace_id": capture.workspaceID.uuidString, + "workspace_ref": capture.workspaceRef, + "surface_id": capture.surfaceID.uuidString, + "surface_ref": capture.surfaceRef, + "window_id": v2OrNull(capture.windowID?.uuidString), + "window_ref": v2OrNull(capture.windowRef), + ] + if let filePath = selection.filePath { + payload["file_path"] = filePath + } + if let lineRange = selection.lineRange { + payload["line_range"] = [ + "start": lineRange.start, + "end": lineRange.end, + ] + } + if let url = selection.url { + payload["url"] = url + } + return .ok(payload) + } + } + + @MainActor + private func captureSurfaceSelection( + params typedParams: [String: JSONValue] + ) async -> SurfaceSelectionSocketCaptureOutcome { + func failure( + code: String, + message: String, + data: [String: String]? = nil + ) -> SurfaceSelectionSocketCaptureOutcome { + .failed(SurfaceSelectionSocketFailure( + code: code, + message: message, + data: data + )) + } + + let params = typedParams.mapValues(\.foundationObject) + let remoteOwnerKey = WorkspaceRemoteRelayCommandRewriter.remoteWorkspaceIDKey + let remoteOwnerWorkspaceID: UUID? + if v2HasNonNullParam(params, remoteOwnerKey) { + guard let rawOwner = params[remoteOwnerKey] as? String, + let parsedOwner = UUID(uuidString: rawOwner) else { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.workspaceNotFound", + defaultValue: "Workspace not found." + ) + ) + } + remoteOwnerWorkspaceID = parsedOwner + } else { + remoteOwnerWorkspaceID = nil + } + let selectorKeys = [ + "window_id", + "group_id", + "workspace_id", + "surface_id", + "terminal_id", + "tab_id", + "pane_id", + ] + // UUID selectors are self-contained. Refresh the registry only when a + // caller supplied an opaque ref that needs resolving; a focused or + // UUID-routed read should not sweep the entire app topology first. + let isOpaqueHandleReference: (String) -> Bool = { raw in + let pieces = raw.split(separator: ":", omittingEmptySubsequences: false) + guard pieces.count == 2, + (ControlHandleKind(rawValue: String(pieces[0]).lowercased()) != nil + || String(pieces[0]).lowercased() == "tab"), + let ordinal = Int(pieces[1]), + ordinal > 0 else { + return false + } + return true + } + let hasUnresolvedOpaqueSelector = selectorKeys.contains { key in + guard v2HasNonNullParam(params, key), + let raw = params[key] as? String else { + return false + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, UUID(uuidString: trimmed) == nil else { + return false + } + guard isOpaqueHandleReference(trimmed) else { return false } + return v2MainSync { v2ResolveHandleRef(trimmed) } == nil + } + if hasUnresolvedOpaqueSelector { + if let pendingRefresh = socketReadSnapshotRefreshTask { + // A startup or notification-driven publication may be about to + // mint the requested ref. Let it finish before declaring the + // opaque selector invalid; the gate still coalesces the live + // fallback refresh after the publication. + await pendingRefresh.value + } + if AppDelegate.shared != nil, + controlCommandCoordinator.needsHandleTopologyRefresh { + v2RefreshKnownRefs() + controlCommandCoordinator.markHandleTopologyRefreshCompleted() + } + } + if let invalidSelector = selectorKeys.first(where: { + v2HasNonNullParam(params, $0) && v2UUID(params, $0) == nil + }) { + return failure( + code: "invalid_params", + message: String( + format: String( + localized: "socket.surfaceSelection.invalidSelector", + defaultValue: "Invalid selector for `%@`." + ), + invalidSelector + ), + data: ["selector": invalidSelector] + ) + } + let routing = ControlRoutingSelectors( + hasWindowIDParam: v2HasNonNullParam(params, "window_id"), + windowID: v2UUID(params, "window_id"), + groupID: v2UUID(params, "group_id"), + workspaceID: v2UUID(params, "workspace_id"), + surfaceID: v2UUID(params, "surface_id") + ?? v2UUID(params, "terminal_id") + ?? v2UUID(params, "tab_id"), + paneID: v2UUID(params, "pane_id") + ) + guard let tabManager = resolveTabManager(routing: routing) else { + return failure( + code: "unavailable", + message: String( + localized: "socket.surfaceSelection.tabManagerUnavailable", + defaultValue: "Selection reading is currently unavailable." + ) + ) + } + func remoteSurfaceBelongsToOwner(_ surfaceID: UUID, ownerWorkspaceID: UUID) -> Bool { + let ownershipRouting = ControlRoutingSelectors( + hasWindowIDParam: false, + windowID: nil, + groupID: nil, + workspaceID: nil, + surfaceID: surfaceID, + paneID: nil + ) + if let workspace = resolveSurfaceWorkspace( + routing: ownershipRouting, + tabManager: tabManager + ), workspace.id == ownerWorkspaceID { + return true + } + return windowDockContainingPanel(surfaceID)?.workspaceId == ownerWorkspaceID + } + if let groupID = routing.groupID, + !tabManager.workspaceGroups.contains(where: { $0.id == groupID }) { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.workspaceNotFound", + defaultValue: "Workspace not found." + ) + ) + } + + let surfaceSelectorKeys = ["surface_id", "terminal_id", "tab_id"] + let hasSurfaceSelector = surfaceSelectorKeys.contains { + v2HasNonNullParam(params, $0) + } + let explicitSurfaceID = routing.surfaceID + if hasSurfaceSelector, explicitSurfaceID == nil { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.surfaceNotFound", + defaultValue: "Surface not found for the given surface selector." + ) + ) + } + + let workspaceID: UUID + let surfaceID: UUID + let windowID: UUID? + let panel: any Panel + if let dock = windowDockForRouting(routing, tabManager: tabManager) { + let target = resolvedWindowDockSurfaceId( + explicitSurfaceID: explicitSurfaceID, + hasSurfaceIDParam: hasSurfaceSelector, + routing: routing, + dock: dock + ) + if target.invalidSurfaceID { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.surfaceNotFound", + defaultValue: "Surface not found for the given surface selector." + ) + ) + } + guard let requestedSurfaceID = target.surfaceID else { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.noFocusedSurface", + defaultValue: "No surface is focused." + ) + ) + } + guard let requestedPanel = dock.panels[requestedSurfaceID] else { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.surfaceNotFound", + defaultValue: "Surface not found for the given surface selector." + ), + data: ["surface_id": requestedSurfaceID.uuidString] + ) + } + workspaceID = dock.workspaceId + surfaceID = requestedSurfaceID + windowID = dockResultWindowId(for: dock, tabManager: tabManager) + panel = requestedPanel + } else { + guard let workspace = resolveSurfaceWorkspace( + routing: routing, + tabManager: tabManager + ) else { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.workspaceNotFound", + defaultValue: "Workspace not found." + ) + ) + } + guard let resolution = workspace.controlRequestedSurfaceTarget( + explicitSurfaceID: explicitSurfaceID, + routedPaneID: routing.paneID + ) else { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.noFocusedSurface", + defaultValue: "No surface is focused." + ) + ) + } + guard let target = resolution.target else { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.surfaceNotFound", + defaultValue: "Surface not found for the given surface selector." + ), + data: ["surface_id": resolution.requestedSurfaceID.uuidString] + ) + } + workspaceID = workspace.id + surfaceID = target.surfaceID + windowID = v2ResolveWindowId(tabManager: tabManager) + panel = target.panel + } + + if let remoteOwnerWorkspaceID, + !remoteSurfaceBelongsToOwner(surfaceID, ownerWorkspaceID: remoteOwnerWorkspaceID) { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.workspaceNotFound", + defaultValue: "Workspace not found." + ) + ) + } + + let snapshot: SurfaceSelectionSnapshot + switch await panel.readSurfaceSelection() { + case .snapshot(let value): + snapshot = value + case .unsupported: + return failure( + code: "not_supported", + message: String( + localized: "socket.surfaceSelection.unsupported", + defaultValue: "This surface does not support selection reads." + ), + data: [ + "surface_id": surfaceID.uuidString, + "kind": panel.panelType.rawValue, + ] + ) + case .unavailable: + return failure( + code: "unavailable", + message: String( + localized: "socket.surfaceSelection.unavailable", + defaultValue: "Selection reading is currently unavailable." + ), + data: [ + "surface_id": surfaceID.uuidString, + "kind": panel.panelType.rawValue, + ] + ) + } + + if let remoteOwnerWorkspaceID, + !remoteSurfaceBelongsToOwner(surfaceID, ownerWorkspaceID: remoteOwnerWorkspaceID) { + return failure( + code: "not_found", + message: String( + localized: "socket.surfaceSelection.workspaceNotFound", + defaultValue: "Workspace not found." + ) + ) + } + + let workspaceRef = v2EnsureHandleRef(kind: .workspace, uuid: workspaceID) + let surfaceRef = v2EnsureHandleRef(kind: .surface, uuid: surfaceID) + let windowRef = windowID.map { + v2EnsureHandleRef(kind: .window, uuid: $0) + } + return .captured(SurfaceSelectionSocketCapture( + snapshot: snapshot, + workspaceID: workspaceID, + surfaceID: surfaceID, + windowID: windowID, + workspaceRef: workspaceRef, + surfaceRef: surfaceRef, + windowRef: windowRef + )) + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 6b8b5fd761e..4d0615ccda0 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -554,10 +554,25 @@ class TerminalController { NotificationCenter.default.addObserver( forName: name, object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - self?.scheduleSocketReadSnapshotRefresh() + // These topology notifications are posted by MainActor-owned + // workspace/window mutations. A nil queue keeps invalidation + // synchronous at the notification boundary. + queue: nil + ) { [weak self] notification in + let topologyChanged = name == .mainWindowContextsDidChange + || name == .workspaceOrderDidChange + || (name == .workspacePaneGeometryDidChange + && notification.userInfo?[GhosttyNotificationKey.topologyChanged] as? Bool == true) + // This observer is explicitly installed on the main queue. + // Invalidate before creating the deferred publication task so + // a queued socket read cannot observe a completed claim after + // the topology has already changed. + MainActor.assumeIsolated { + guard let self else { return } + if topologyChanged { + self.invalidateSocketHandleTopologyRefresh() + } + self.scheduleSocketReadSnapshotRefresh() } } } @@ -1185,6 +1200,14 @@ class TerminalController { return response } } + if request.method == "surface.read_selection" { + return v2AsyncResultCall( + id: request.id, + timeoutSeconds: 5 + ) { + await self.v2SurfaceReadSelection(params: parsedRequest.params) + } + } if request.method == "mobile.task.models.list" { return v2AsyncResultCall( id: request.id, @@ -2976,6 +2999,7 @@ class TerminalController { "surface.report_shell_state", "surface.ports_kick", "surface.read_text", + "surface.read_selection", "surface.clear_history", "surface.trigger_flash", "pane.list", @@ -4049,7 +4073,7 @@ class TerminalController { return Self.v2Encoder.encode(value) } - private func v2EnsureHandleRef(kind: ControlHandleKind, uuid: UUID) -> String { + func v2EnsureHandleRef(kind: ControlHandleKind, uuid: UUID) -> String { controlCommandCoordinator.ensureRef(kind: kind, uuid: uuid) } @@ -4113,6 +4137,7 @@ class TerminalController { } } } + controlCommandCoordinator.markHandleTopologyRefreshCompleted() } // MARK: - V2 Context Resolution diff --git a/Sources/Workspace+RemoteTmuxControlTopology.swift b/Sources/Workspace+RemoteTmuxControlTopology.swift index 6833eb72439..69c69c78ad3 100644 --- a/Sources/Workspace+RemoteTmuxControlTopology.swift +++ b/Sources/Workspace+RemoteTmuxControlTopology.swift @@ -375,17 +375,30 @@ extension Workspace { } /// Resolves explicit-or-default control-plane surface targeting. An - /// explicit surface id (or a routed tmux pane's surface) canonicalizes - /// fail-closed via ``controlSurfaceTarget(for:)``; the focused default - /// projects a mirror container to its tmux-active pane like - /// `surface.current`. Returns nil when nothing is focused. + /// explicit surface id or pane-selected surface canonicalizes fail-closed + /// via ``controlSurfaceTarget(for:)``; the focused default projects a + /// mirror container to its tmux-active pane like `surface.current`. + /// Returns nil when an explicit pane cannot resolve or nothing is focused. func controlRequestedSurfaceTarget( explicitSurfaceID: UUID?, routedPaneID: UUID? ) -> (requestedSurfaceID: UUID, target: ControlSurfaceProjection?)? { - if let explicit = explicitSurfaceID - ?? routedPaneID.flatMap({ remoteTmuxControlPane(paneID: $0)?.pane.panel.id }) { - return (explicit, controlSurfaceTarget(for: explicit)) + if let explicitSurfaceID { + return (explicitSurfaceID, controlSurfaceTarget(for: explicitSurfaceID)) + } + if let routedPaneID { + if let remote = remoteTmuxControlPane(paneID: routedPaneID) { + let surfaceID = remote.pane.panel.id + return (surfaceID, controlSurfaceTarget(for: surfaceID)) + } + guard let paneID = bonsplitController.allPaneIds.first(where: { + $0.id == routedPaneID + }), + let selectedTab = bonsplitController.selectedTab(inPane: paneID), + let panelID = panelIdFromSurfaceId(selectedTab.id) else { + return nil + } + return (panelID, controlSurfaceTarget(for: panelID)) } guard let focusedPanelId else { return nil } return (focusedPanelId, controlSurfaceProjection(forContainerPanelID: focusedPanelId)) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 6044a5cc2e4..44cc9f92792 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14626,10 +14626,15 @@ extension Workspace: BonsplitDelegate { func splitTabBar(_ controller: BonsplitController, didChangeGeometry snapshot: LayoutSnapshot) { tmuxLayoutSnapshot = snapshot + _ = surfaceList.registerGeometryChange() + let topologyChanged = surfaceList.lastGeometryChangeChangedMembership NotificationCenter.default.post( name: .workspacePaneGeometryDidChange, object: self, - userInfo: [GhosttyNotificationKey.tabId: id] + userInfo: [ + GhosttyNotificationKey.tabId: id, + GhosttyNotificationKey.topologyChanged: topologyChanged, + ] ) // Every order/membership mutation (same-pane reorder, cross-pane move, // split, close) routes through here. A pure reorder mutates only @@ -14637,7 +14642,6 @@ extension Workspace: BonsplitDelegate { // would miss it. Bump `paneLayoutVersion` only when the ordered panel-id // sequence actually changed, so divider drags and selection-only events // (also routed here) do not fire `objectWillChange` app-wide. - surfaceList.registerGeometryChange() scheduleTerminalGeometryReconcile() if !isDetachingCloseTransaction { scheduleFocusReconcile() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f0ded2cc197..3ba9e291fd6 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -780,6 +780,7 @@ D0F101000000000000000001 /* CMUXCLI+SSHPTYInputControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0F101000000000000000011 /* CMUXCLI+SSHPTYInputControl.swift */; }; 6702F211735472DC14581CA7 /* CMUXCLI+SSHReconnectPrompt.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF61A4F72B75DC4EA47DF6F /* CMUXCLI+SSHReconnectPrompt.swift */; }; CBF07000000000000000002 /* CMUXCLI+SSHStartupScripts.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBF07000000000000000001 /* CMUXCLI+SSHStartupScripts.swift */; }; + A10020000000000000000007 /* CMUXCLI+SurfaceSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000008 /* CMUXCLI+SurfaceSelection.swift */; }; B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */; }; B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */; }; B9000048A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000049A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift */; }; @@ -1660,6 +1661,7 @@ 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */; }; 596100000000000000000001 /* NativeNotificationFallbackCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 596100000000000000000002 /* NativeNotificationFallbackCommandTests.swift */; }; A6AC72030000000000000001 /* NativeSpinner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC72030000000000000002 /* NativeSpinner.swift */; }; + A1002000000000000000000F /* NativeTextSurfaceSelectionReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000010 /* NativeTextSurfaceSelectionReader.swift */; }; 8B1B2AB6EC6ACDB2CD39A9BB /* NewBrowserWorkspaceShortcutUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04698069140539D8495D6B9B /* NewBrowserWorkspaceShortcutUITests.swift */; }; 5CA1AFB486DF86FAF22509F2 /* NewMachineModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751BACEC44FF7CFC02CE9A57 /* NewMachineModel.swift */; }; D5EB22366F37AB2B52CD4B2A /* NewMachineModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5130177E80C2A18E09DBBBC4 /* NewMachineModelTests.swift */; }; @@ -2436,6 +2438,14 @@ F6572022A1B2C3D4E5F60718 /* SurfaceResumeRestoreClaimTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6572023A1B2C3D4E5F60718 /* SurfaceResumeRestoreClaimTests.swift */; }; F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */; }; A5001303 /* SurfaceSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001301 /* SurfaceSearchOverlay.swift */; }; + A10020000000000000000001 /* SurfaceSelectionLineRange.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000002 /* SurfaceSelectionLineRange.swift */; }; + A10020000000000000000017 /* SurfaceSelectionNavigationLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000018 /* SurfaceSelectionNavigationLoader.swift */; }; + A1002000000000000000000D /* SurfaceSelectionReadResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1002000000000000000000E /* SurfaceSelectionReadResult.swift */; }; + A1002000000000000000000B /* SurfaceSelectionSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1002000000000000000000C /* SurfaceSelectionSnapshot.swift */; }; + A10020000000000000000003 /* SurfaceSelectionSocketCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000004 /* SurfaceSelectionSocketCapture.swift */; }; + A10020000000000000000015 /* SurfaceSelectionSocketCaptureOutcome.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000016 /* SurfaceSelectionSocketCaptureOutcome.swift */; }; + A10020000000000000000013 /* SurfaceSelectionSocketFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000014 /* SurfaceSelectionSocketFailure.swift */; }; + A10020000000000000000009 /* SurfaceSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1002000000000000000000A /* SurfaceSelectionTests.swift */; }; 6F8CE4B13D081CF9C5C370B3 /* SurfaceSocketCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 278BEFB3583AED9627966DFF /* SurfaceSocketCommands.swift */; }; C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; }; F2100000000000000000000F /* SyntheticKeyEventFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1100000000000000000000F /* SyntheticKeyEventFactory.swift */; }; @@ -2579,6 +2589,7 @@ A79840030000000000000002 /* TerminalController+SocketConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79840030000000000000001 /* TerminalController+SocketConfiguration.swift */; }; C79470020000000000000001 /* TerminalController+SocketListenerRearm.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79470020000000000000002 /* TerminalController+SocketListenerRearm.swift */; }; C0DE00000000000000000F01 /* TerminalController+SSHSessionAttachContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000F02 /* TerminalController+SSHSessionAttachContext.swift */; }; + A10020000000000000000005 /* TerminalController+SurfaceSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000006 /* TerminalController+SurfaceSelection.swift */; }; D6212D0C00000000000000D3 /* TerminalController+WindowDockBrowserRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6212D0C00000000000000D4 /* TerminalController+WindowDockBrowserRouting.swift */; }; 9065A0010000000000000001 /* TerminalController+WindowScreenshotCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9065A0010000000000000002 /* TerminalController+WindowScreenshotCapture.swift */; }; C0DE00000000000000000C84 /* TerminalController+WorkspaceCreate.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */; }; @@ -2829,6 +2840,8 @@ C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000002 /* VMSSHCommandTests.swift */; }; B43AFCE7099305905825EA0A /* VMTunnelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */; }; ABE558B281D671A6DDDDD066 /* VMTunnelManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74A47FAD328D6669A5B40CE3 /* VMTunnelManagerTests.swift */; }; + A10020000000000000000019 /* WebSurfaceSelectionEvaluationOwner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1002000000000000000001A /* WebSurfaceSelectionEvaluationOwner.swift */; }; + A10020000000000000000011 /* WebSurfaceSelectionReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10020000000000000000012 /* WebSurfaceSelectionReader.swift */; }; A500120C /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001222 /* WindowAccessor.swift */; }; 063BC42CEE257D6213A2E30C /* WindowAndDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */; }; A59170000000000000000001 /* WindowAppearanceSnapshotPaneBackgroundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59170000000000000000002 /* WindowAppearanceSnapshotPaneBackgroundTests.swift */; }; @@ -3883,6 +3896,7 @@ D0F101000000000000000011 /* CMUXCLI+SSHPTYInputControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHPTYInputControl.swift"; sourceTree = "<group>"; }; DCF61A4F72B75DC4EA47DF6F /* CMUXCLI+SSHReconnectPrompt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHReconnectPrompt.swift"; sourceTree = "<group>"; }; CBF07000000000000000001 /* CMUXCLI+SSHStartupScripts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHStartupScripts.swift"; sourceTree = "<group>"; }; + A10020000000000000000008 /* CMUXCLI+SurfaceSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SurfaceSelection.swift"; sourceTree = "<group>"; }; B900002CA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Themes.swift"; sourceTree = "<group>"; }; B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ThemeSupport.swift"; sourceTree = "<group>"; }; B9000049A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TmuxCompatHUDSupport.swift"; sourceTree = "<group>"; }; @@ -4693,6 +4707,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 596100000000000000000004 /* NativeNotificationDeliveryHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeNotificationDeliveryHooks.swift; sourceTree = "<group>"; }; 596100000000000000000002 /* NativeNotificationFallbackCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeNotificationFallbackCommandTests.swift; sourceTree = "<group>"; }; A6AC72030000000000000002 /* NativeSpinner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeSpinner.swift; sourceTree = "<group>"; }; + A10020000000000000000010 /* NativeTextSurfaceSelectionReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/NativeTextSurfaceSelectionReader.swift; sourceTree = "<group>"; }; 04698069140539D8495D6B9B /* NewBrowserWorkspaceShortcutUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewBrowserWorkspaceShortcutUITests.swift; sourceTree = "<group>"; }; 751BACEC44FF7CFC02CE9A57 /* NewMachineModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewMachineModel.swift; sourceTree = "<group>"; }; 5130177E80C2A18E09DBBBC4 /* NewMachineModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewMachineModelTests.swift; sourceTree = "<group>"; }; @@ -5456,6 +5471,14 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F6572023A1B2C3D4E5F60718 /* SurfaceResumeRestoreClaimTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRestoreClaimTests.swift; sourceTree = "<group>"; }; F27B00000000000000000002 /* SurfaceResumeRunPromptBatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRunPromptBatch.swift; sourceTree = "<group>"; }; A5001301 /* SurfaceSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/SurfaceSearchOverlay.swift; sourceTree = "<group>"; }; + A10020000000000000000002 /* SurfaceSelectionLineRange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/SurfaceSelectionLineRange.swift; sourceTree = "<group>"; }; + A10020000000000000000018 /* SurfaceSelectionNavigationLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceSelectionNavigationLoader.swift; sourceTree = "<group>"; }; + A1002000000000000000000E /* SurfaceSelectionReadResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/SurfaceSelectionReadResult.swift; sourceTree = "<group>"; }; + A1002000000000000000000C /* SurfaceSelectionSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/SurfaceSelectionSnapshot.swift; sourceTree = "<group>"; }; + A10020000000000000000004 /* SurfaceSelectionSocketCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceSelectionSocketCapture.swift; sourceTree = "<group>"; }; + A10020000000000000000016 /* SurfaceSelectionSocketCaptureOutcome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceSelectionSocketCaptureOutcome.swift; sourceTree = "<group>"; }; + A10020000000000000000014 /* SurfaceSelectionSocketFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceSelectionSocketFailure.swift; sourceTree = "<group>"; }; + A1002000000000000000000A /* SurfaceSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceSelectionTests.swift; sourceTree = "<group>"; }; 278BEFB3583AED9627966DFF /* SurfaceSocketCommands.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SurfaceSocketCommands.swift; sourceTree = "<group>"; }; C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceTabBarButtonConfiguration.swift; sourceTree = "<group>"; }; F1100000000000000000000F /* SyntheticKeyEventFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticKeyEventFactory.swift; sourceTree = "<group>"; }; @@ -5599,6 +5622,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A79840030000000000000001 /* TerminalController+SocketConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SocketConfiguration.swift"; sourceTree = "<group>"; }; C79470020000000000000002 /* TerminalController+SocketListenerRearm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SocketListenerRearm.swift"; sourceTree = "<group>"; }; C0DE00000000000000000F02 /* TerminalController+SSHSessionAttachContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SSHSessionAttachContext.swift"; sourceTree = "<group>"; }; + A10020000000000000000006 /* TerminalController+SurfaceSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SurfaceSelection.swift"; sourceTree = "<group>"; }; D6212D0C00000000000000D4 /* TerminalController+WindowDockBrowserRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WindowDockBrowserRouting.swift"; sourceTree = "<group>"; }; 9065A0010000000000000002 /* TerminalController+WindowScreenshotCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WindowScreenshotCapture.swift"; sourceTree = "<group>"; }; C0DE00000000000000000C83 /* TerminalController+WorkspaceCreate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+WorkspaceCreate.swift"; sourceTree = "<group>"; }; @@ -5848,6 +5872,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C37800000000000000000002 /* VMSSHCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSSHCommandTests.swift; sourceTree = "<group>"; }; 03EC85CE06E4F64991DAA433 /* VMTunnelManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMTunnelManager.swift; sourceTree = "<group>"; }; 74A47FAD328D6669A5B40CE3 /* VMTunnelManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMTunnelManagerTests.swift; sourceTree = "<group>"; }; + A1002000000000000000001A /* WebSurfaceSelectionEvaluationOwner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebSurfaceSelectionEvaluationOwner.swift; sourceTree = "<group>"; }; + A10020000000000000000012 /* WebSurfaceSelectionReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/WebSurfaceSelectionReader.swift; sourceTree = "<group>"; }; A5001222 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; }; BEE83F8394D90ACACD8E19DD /* WindowAndDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAndDragTests.swift; sourceTree = "<group>"; }; A59170000000000000000002 /* WindowAppearanceSnapshotPaneBackgroundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAppearanceSnapshotPaneBackgroundTests.swift; sourceTree = "<group>"; }; @@ -7349,6 +7375,10 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */, E7E000000000000000000008 /* CmuxEventPublishing.swift */, A5001019 /* TerminalController.swift */, + A10020000000000000000004 /* SurfaceSelectionSocketCapture.swift */, + A10020000000000000000016 /* SurfaceSelectionSocketCaptureOutcome.swift */, + A10020000000000000000014 /* SurfaceSelectionSocketFailure.swift */, + A10020000000000000000006 /* TerminalController+SurfaceSelection.swift */, 9065A0010000000000000002 /* TerminalController+WindowScreenshotCapture.swift */, 9065A0020000000000000002 /* WindowAppKitCapture.swift */, 9065A0090000000000000002 /* WindowScreenshotBackendAttempt.swift */, @@ -7712,6 +7742,12 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 3865B0033865B0033865B003 /* MenubarSearchPopover.swift */, D10786DB0000000000000002 /* DeferredBrowserPanel.swift */, A5001410 /* Panel.swift */, + A10020000000000000000010 /* NativeTextSurfaceSelectionReader.swift */, + A10020000000000000000002 /* SurfaceSelectionLineRange.swift */, + A1002000000000000000000E /* SurfaceSelectionReadResult.swift */, + A1002000000000000000000C /* SurfaceSelectionSnapshot.swift */, + A10020000000000000000012 /* WebSurfaceSelectionReader.swift */, + A1002000000000000000001A /* WebSurfaceSelectionEvaluationOwner.swift */, A85630010000000000000001 /* WorkspaceAttentionColor.swift */, C54860060000000000000002 /* PanelStableSurfaceIdentity.swift */, A5F0C0010000000000000001 /* WorkspaceAttentionFlashRingView.swift */, @@ -8450,6 +8486,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C05555010000000000000008 /* CMUXCLI+PiExtensionSourcePart2.swift */, C08672010000000000000008 /* PiCompactedFeedEventExpander.swift */, B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */, + A10020000000000000000008 /* CMUXCLI+SurfaceSelection.swift */, 906902000000000000000001 /* CMUXCLI+MemoryAttribution.swift */, C51A75000000000000000002 /* CMUXCLI+JSONOutput.swift */, C51A73000000000000000002 /* CMUXCLI+Simulator.swift */, @@ -8861,6 +8898,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE31390000000000000112 /* CMUXOpenHTMLFocusTests.swift */, C0DE75890000000000000004 /* DeflatedAssetTestSupport.swift */, 1590EE4F01FEF02D40A48698 /* CLIExplicitSurfaceRoutingTests.swift */, + A1002000000000000000000A /* SurfaceSelectionTests.swift */, + A10020000000000000000018 /* SurfaceSelectionNavigationLoader.swift */, 93300002A1B2C3D4E5F60718 /* SocketTerminalBindingRegressionTests.swift */, A7367002A1B2C3D4E5F60718 /* CLISSHSessionAttachAnchorTests.swift */, C0DEBACC0000000000000004 /* CodexTeamsAppServerFixture.swift */, @@ -10739,6 +10778,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D75280030000000000000001 /* MobileWorkspaceObserverSignposts.swift in Sources */, 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */, A6AC72030000000000000001 /* NativeSpinner.swift in Sources */, + A1002000000000000000000F /* NativeTextSurfaceSelectionReader.swift in Sources */, 5CA1AFB486DF86FAF22509F2 /* NewMachineModel.swift in Sources */, DD3322FE8D4021EB0FAFE7F6 /* NewMachineSheet.swift in Sources */, 548D90758DB3CF890BFDC603 /* NewMachineSheetPresenter.swift in Sources */, @@ -11254,6 +11294,12 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */, F27B00000000000000000001 /* SurfaceResumeRunPromptBatch.swift in Sources */, A5001303 /* SurfaceSearchOverlay.swift in Sources */, + A10020000000000000000001 /* SurfaceSelectionLineRange.swift in Sources */, + A1002000000000000000000D /* SurfaceSelectionReadResult.swift in Sources */, + A1002000000000000000000B /* SurfaceSelectionSnapshot.swift in Sources */, + A10020000000000000000003 /* SurfaceSelectionSocketCapture.swift in Sources */, + A10020000000000000000015 /* SurfaceSelectionSocketCaptureOutcome.swift in Sources */, + A10020000000000000000013 /* SurfaceSelectionSocketFailure.swift in Sources */, 6F8CE4B13D081CF9C5C370B3 /* SurfaceSocketCommands.swift in Sources */, C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */, F2100000000000000000000F /* SyntheticKeyEventFactory.swift in Sources */, @@ -11378,6 +11424,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A79840030000000000000002 /* TerminalController+SocketConfiguration.swift in Sources */, C79470020000000000000001 /* TerminalController+SocketListenerRearm.swift in Sources */, C0DE00000000000000000F01 /* TerminalController+SSHSessionAttachContext.swift in Sources */, + A10020000000000000000005 /* TerminalController+SurfaceSelection.swift in Sources */, D6212D0C00000000000000D3 /* TerminalController+WindowDockBrowserRouting.swift in Sources */, 9065A0010000000000000001 /* TerminalController+WindowScreenshotCapture.swift in Sources */, C0DE00000000000000000C84 /* TerminalController+WorkspaceCreate.swift in Sources */, @@ -11564,6 +11611,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */, F1C699902D31503EE3D492DF /* VMMachineKind.swift in Sources */, B43AFCE7099305905825EA0A /* VMTunnelManager.swift in Sources */, + A10020000000000000000019 /* WebSurfaceSelectionEvaluationOwner.swift in Sources */, + A10020000000000000000011 /* WebSurfaceSelectionReader.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, 9065A0020000000000000001 /* WindowAppKitCapture.swift in Sources */, A5001900A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift in Sources */, @@ -11831,6 +11880,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef D0F101000000000000000001 /* CMUXCLI+SSHPTYInputControl.swift in Sources */, 6702F211735472DC14581CA7 /* CMUXCLI+SSHReconnectPrompt.swift in Sources */, CBF07000000000000000002 /* CMUXCLI+SSHStartupScripts.swift in Sources */, + A10020000000000000000007 /* CMUXCLI+SurfaceSelection.swift in Sources */, B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */, B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */, B9000048A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift in Sources */, @@ -12585,6 +12635,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef F6572012A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift in Sources */, 842300000000000000000001 /* SurfaceResumeExitedAgentLivenessTests.swift in Sources */, F6572022A1B2C3D4E5F60718 /* SurfaceResumeRestoreClaimTests.swift in Sources */, + A10020000000000000000017 /* SurfaceSelectionNavigationLoader.swift in Sources */, + A10020000000000000000009 /* SurfaceSelectionTests.swift in Sources */, A7206B010000000000000001 /* SystemAppearanceObserverTests.swift in Sources */, FBE660ABA1854983B84368C6 /* SystemWideHotkeyShortcutPolicyTests.swift in Sources */, F8120F5386FBE726864D340B /* TabManagerBackgroundWorkspaceMountBoundTests.swift in Sources */, diff --git a/cmuxTests/BrowserPanelViewIdentityTests.swift b/cmuxTests/BrowserPanelViewIdentityTests.swift index 64c3a03e514..6df6bfda25b 100644 --- a/cmuxTests/BrowserPanelViewIdentityTests.swift +++ b/cmuxTests/BrowserPanelViewIdentityTests.swift @@ -94,7 +94,6 @@ import WebKit webView.layoutPassCount = 0 portal.forceRefreshWebView(withId: ObjectIdentifier(webView), reason: "test") - // Observe the synchronous boundary before yielding to AppKit's display // cycle. Once the run loop spins, a dirty NSHostingView may be laid out // by the window system independently of the portal's deferred WebKit diff --git a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift index 40fe29101e6..db538623fad 100644 --- a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift +++ b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift @@ -9,6 +9,10 @@ struct CLIExplicitSurfaceRoutingTests { arguments: ["read-screen", "--surface", Self.targetSurfaceRef, "--lines", "5"], expectedMethod: "surface.read_text" ) + try assertExplicitSurfaceCommand( + arguments: ["read-selection", "--surface", Self.targetSurfaceRef], + expectedMethod: "surface.read_selection" + ) try assertExplicitSurfaceCommand( arguments: ["send", "--surface", Self.targetSurfaceRef, "hello"], expectedMethod: "surface.send_text", @@ -46,6 +50,7 @@ struct CLIExplicitSurfaceRoutingTests { Darwin.close(listenerFD) unlink(socketPath) } + let state = ServerState() let handled = Self.startMockServer(listenerFD: listenerFD, state: state) { line in guard let payload = Self.jsonObject(line), @@ -75,10 +80,10 @@ struct CLIExplicitSurfaceRoutingTests { executablePath: try Self.bundledCLIPath(), arguments: ["read-screen", "--surface", "5", "--lines", "1"], environment: cliEnvironment(socketPath: socketPath), - timeout: 5 + timeout: Self.processTimeout ) - #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) #expect(state.errorsSnapshot().isEmpty, Comment(rawValue: state.errorsSnapshot().joined(separator: "\n"))) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status == 0, Comment(rawValue: result.stderr + result.stdout)) @@ -95,6 +100,116 @@ struct CLIExplicitSurfaceRoutingTests { #expect(readParams["surface_id"] as? String == Self.numericSurfaceId) } + @Test func readSelectionPlainOutputIncludesSourceContext() throws { + let (result, requests) = try runSelectionCommand( + name: "plain", + arguments: ["read-selection", "--surface", Self.targetSurfaceRef], + payload: Self.selectedSourcePayload + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr + result.stdout)) + #expect(result.stdout.contains("filepreview")) + #expect(result.stdout.contains("/tmp/example.swift")) + #expect(result.stdout.contains("2-3")) + #expect(result.stdout.contains("selected source")) + #expect(requests.compactMap { $0["method"] as? String } == ["surface.read_selection"]) + } + + @Test func readSelectionJSONPreservesTheCompleteNoSelectionShape() throws { + let payload: [String: Any] = [ + "has_selection": false, + "kind": "browser", + "text": "", + "base64": "", + "url": "https://example.test/document", + "workspace_id": Self.callerWorkspaceId, + "workspace_ref": "workspace:1", + "surface_id": Self.callerSurfaceId, + "surface_ref": "surface:1", + ] + let (result, requests) = try runSelectionCommand( + name: "json", + arguments: ["read-selection", "--surface", Self.targetSurfaceRef, "--json"], + payload: payload + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr + result.stdout)) + let output = try #require(Self.jsonObject( + result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + )) + #expect(output["has_selection"] as? Bool == false) + #expect(output["kind"] as? String == "browser") + #expect(output["text"] as? String == "") + #expect(output["base64"] as? String == "") + #expect(output["url"] as? String == "https://example.test/document") + #expect(output["workspace_id"] as? String == Self.callerWorkspaceId) + #expect(output["workspace_ref"] as? String == "workspace:1") + #expect(output["surface_id"] as? String == Self.callerSurfaceId) + #expect(output["surface_ref"] as? String == "surface:1") + #expect(requests.compactMap { $0["method"] as? String } == ["surface.read_selection"]) + } + + @Test func plainSelectionReadsSucceedWithAnExplicitNoSelectionMarker() throws { + let payload: [String: Any] = [ + "has_selection": false, + "kind": "browser", + "text": "", + "base64": "", + "url": "https://example.test/document", + ] + let (result, requests) = try runSelectionCommand( + name: "plain-empty", + arguments: ["read-selection", "--surface", Self.targetSurfaceRef], + payload: payload + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr + result.stdout)) + #expect(result.stdout.contains("Kind: browser")) + #expect(result.stdout.contains("URL: https://example.test/document")) + #expect(result.stdout.hasSuffix("Has selection: false\n")) + #expect(requests.compactMap { $0["method"] as? String } == ["surface.read_selection"]) + } + + @Test func readScreenSelectionAliasIsTextOnlyAndSucceedsWithoutASelection() throws { + let (selectedResult, selectedRequests) = try runSelectionCommand( + name: "alias", + arguments: ["read-screen", "--surface", Self.targetSurfaceRef, "--selection"], + payload: Self.selectedSourcePayload + ) + #expect(selectedResult.status == 0, Comment(rawValue: selectedResult.stderr + selectedResult.stdout)) + #expect(selectedResult.stdout == "selected source\n") + #expect(selectedRequests.compactMap { $0["method"] as? String } == ["surface.read_selection"]) + + let (emptyResult, emptyRequests) = try runSelectionCommand( + name: "empty", + arguments: ["read-screen", "--surface", Self.targetSurfaceRef, "--selection"], + payload: [ + "has_selection": false, + "kind": "terminal", + "text": "", + "base64": "", + ] + ) + #expect(emptyResult.status == 0, Comment(rawValue: emptyResult.stderr + emptyResult.stdout)) + #expect(emptyResult.stdout == "Has selection: false\n") + #expect(emptyRequests.compactMap { $0["method"] as? String } == ["surface.read_selection"]) + } + + @Test func globalWindowSelectionReadDoesNotPrefocusTheAppWindow() throws { + let (result, requests) = try runSelectionCommand( + name: "window", + arguments: ["--window", Self.reproWindowId, "read-selection"], + payload: Self.selectedSourcePayload + ) + + #expect(result.status == 0, Comment(rawValue: result.stderr + result.stdout)) + #expect(requests.compactMap { $0["method"] as? String } == ["surface.read_selection"]) + let params = try #require(requests.first?["params"] as? [String: Any]) + #expect(params["window_id"] as? String == Self.reproWindowId) + #expect(params["workspace_id"] == nil) + #expect(params["surface_id"] == nil) + } + @Test func refusedTerminalReadsAndWritesExitNonzero() throws { let cases: [([String], String)] = [ (["read-screen", "--surface", Self.targetSurfaceRef], "surface.read_text"), @@ -188,10 +303,10 @@ struct CLIExplicitSurfaceRoutingTests { "--workspace", Self.reproWorkspaceRef, ], environment: cliEnvironment(socketPath: socketPath), - timeout: 5 + timeout: Self.processTimeout ) - #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) #expect(state.errorsSnapshot().isEmpty, Comment(rawValue: state.errorsSnapshot().joined(separator: "\n"))) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status != 0, Comment(rawValue: result.stderr + result.stdout)) @@ -240,10 +355,10 @@ struct CLIExplicitSurfaceRoutingTests { "--surface", Self.missingSurfaceUUID, ], environment: environment, - timeout: 5 + timeout: Self.processTimeout ) - #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) #expect(state.errorsSnapshot().isEmpty, Comment(rawValue: state.errorsSnapshot().joined(separator: "\n"))) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status != 0, Comment(rawValue: result.stderr + result.stdout)) @@ -290,10 +405,10 @@ struct CLIExplicitSurfaceRoutingTests { "--surface", " ", ], environment: environment, - timeout: 5 + timeout: Self.processTimeout ) - #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) #expect(state.errorsSnapshot().isEmpty, Comment(rawValue: state.errorsSnapshot().joined(separator: "\n"))) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status != 0, Comment(rawValue: result.stderr + result.stdout)) @@ -354,10 +469,10 @@ struct CLIExplicitSurfaceRoutingTests { "--command", "echo nope", ], environment: cliEnvironment(socketPath: socketPath), - timeout: 5 + timeout: Self.processTimeout ) - #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) #expect(state.errorsSnapshot().isEmpty, Comment(rawValue: state.errorsSnapshot().joined(separator: "\n"))) #expect(!result.timedOut, Comment(rawValue: result.stderr)) #expect(result.status != 0, Comment(rawValue: result.stderr + result.stdout)) @@ -376,10 +491,15 @@ struct CLIExplicitSurfaceRoutingTests { expectedText: String? = nil, expectedKey: String? = nil ) throws { - let execution = try runMockCommand( - arguments: arguments, - socketName: expectedMethod - ) { line in + let socketPath = Self.makeSocketPath(expectedMethod) + let listenerFD = try Self.bindUnixSocket(at: socketPath) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let state = ServerState() + let handled = Self.startMockServer(listenerFD: listenerFD, state: state) { line in guard let payload = Self.jsonObject(line), let id = payload["id"] as? String, let method = payload["method"] as? String else { @@ -388,6 +508,14 @@ struct CLIExplicitSurfaceRoutingTests { switch method { case "surface.read_text": return Self.v2Response(id: id, ok: true, result: ["text": "agent screen\n"]) + case "surface.read_selection": + return Self.v2Response(id: id, ok: true, result: [ + "has_selection": true, + "kind": "filepreview", + "text": "selected source", + "file_path": "/tmp/example.swift", + "line_range": ["start": 2, "end": 3], + ]) case "surface.send_text", "surface.send_key": return Self.v2Response(id: id, ok: true, result: ["surface_id": Self.targetSurfaceRef]) default: @@ -399,13 +527,19 @@ struct CLIExplicitSurfaceRoutingTests { } } - #expect(!execution.result.timedOut, Comment(rawValue: execution.result.stderr)) - #expect( - execution.result.status == 0, - Comment(rawValue: execution.result.stderr + execution.result.stdout) + let result = Self.runProcess( + executablePath: try Self.bundledCLIPath(), + arguments: arguments, + environment: cliEnvironment(socketPath: socketPath), + timeout: Self.processTimeout ) - let requests = try execution.state.requestObjects() + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) + #expect(state.errorsSnapshot().isEmpty, Comment(rawValue: state.errorsSnapshot().joined(separator: "\n"))) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr + result.stdout)) + + let requests = try state.requestObjects() #expect(requests.compactMap { $0["method"] as? String } == [expectedMethod]) let request = try #require(requests.first) let params = try #require(request["params"] as? [String: Any]) @@ -445,15 +579,63 @@ struct CLIExplicitSurfaceRoutingTests { executablePath: try Self.bundledCLIPath(), arguments: arguments, environment: environment, - timeout: 5 + timeout: Self.processTimeout ) - #expect(handled.wait(timeout: .now() + 5) == .success) + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) let errors = state.errorsSnapshot() #expect(errors.isEmpty, Comment(rawValue: errors.joined(separator: "\n"))) return (result, state) } + private func runSelectionCommand( + name: String, + arguments: [String], + payload: [String: Any] + ) throws -> (result: ProcessRunResult, requests: [[String: Any]]) { + let socketPath = Self.makeSocketPath(name) + let listenerFD = try Self.bindUnixSocket(at: socketPath) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let state = ServerState() + let payloadData = try JSONSerialization.data(withJSONObject: payload) + let payloadJSON = try #require(String(data: payloadData, encoding: .utf8)) + let handled = Self.startMockServer(listenerFD: listenerFD, state: state) { line in + guard let request = Self.jsonObject(line), + let id = request["id"] as? String, + let method = request["method"] as? String else { + return Self.malformedRequestResponse(raw: line) + } + guard method == "surface.read_selection" else { + return Self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected_method", "message": method] + ) + } + return Self.v2Response( + id: id, + ok: true, + result: Self.jsonObject(payloadJSON) + ) + } + + let result = Self.runProcess( + executablePath: try Self.bundledCLIPath(), + arguments: arguments, + environment: cliEnvironment(socketPath: socketPath), + timeout: Self.processTimeout + ) + + #expect(handled.wait(timeout: .now() + Self.serverTimeout) == .success) + #expect(state.errorsSnapshot().isEmpty, Comment(rawValue: state.errorsSnapshot().joined(separator: "\n"))) + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + return (result, try state.requestObjects()) + } + private func cliEnvironment(socketPath: String) -> [String: String] { var environment = ProcessInfo.processInfo.environment environment["CMUX_SOCKET_PATH"] = socketPath @@ -465,6 +647,11 @@ struct CLIExplicitSurfaceRoutingTests { } private static let callerWorkspaceId = "11111111-1111-1111-1111-111111111111" + // CI runners can cold-start the bundled CLI and its Swift runtime in a few + // seconds. Keep these waits bounded, but leave enough headroom that a slow + // runner does not turn an otherwise handled socket response into a timeout. + private static let processTimeout: TimeInterval = 60 + private static let serverTimeout: TimeInterval = 60 private static let callerSurfaceId = "22222222-2222-2222-2222-222222222222" private static let targetSurfaceRef = "surface:11" private static let numericSurfaceId = "33333333-3333-3333-3333-333333333333" @@ -475,6 +662,20 @@ struct CLIExplicitSurfaceRoutingTests { private static let reproSecondSurfaceId = "44444444-4444-4444-4444-444444443223" private static let reproThirdSurfaceId = "44444444-4444-4444-4444-444444443224" private static let missingSurfaceUUID = "99999999-9999-9999-9999-999999999999" + private static var selectedSourcePayload: [String: Any] { + [ + "has_selection": true, + "kind": "filepreview", + "text": "selected source", + "base64": "c2VsZWN0ZWQgc291cmNl", + "file_path": "/tmp/example.swift", + "line_range": ["start": 2, "end": 3], + "workspace_id": callerWorkspaceId, + "workspace_ref": "workspace:1", + "surface_id": callerSurfaceId, + "surface_ref": "surface:1", + ] + } private static var reproSurfaceRows: [[String: Any]] { [ ["id": reproSelectedSurfaceId, "ref": "surface:3222", "index": 0, "focused": true], diff --git a/cmuxTests/RemoteResumeBindingTests.swift b/cmuxTests/RemoteResumeBindingTests.swift index fc37c17ee73..85304366cb3 100644 --- a/cmuxTests/RemoteResumeBindingTests.swift +++ b/cmuxTests/RemoteResumeBindingTests.swift @@ -492,6 +492,7 @@ struct RemoteResumeBindingTests { let workspace = try #require(manager.selectedWorkspace) let surfaceID = try #require(workspace.focusedPanelId) + let remoteSurfaceID = UUID() workspace.configureRemoteConnection(remoteConfiguration(), autoConnect: false) let relayToken = try #require(workspace.remoteConfiguration?.relayToken) let rewriter = WorkspaceRemoteRelayCommandRewriter( @@ -511,6 +512,37 @@ struct RemoteResumeBindingTests { let pingEnvelope = try v2Envelope(requestData: ping) #expect(pingEnvelope["ok"] as? Bool == true, "\(pingEnvelope)") + let readSelection = rewriter.rewriteRemoteRelayCommandLine( + try requestData([ + "id": "relay-read-selection", + "method": "surface.read_selection", + "params": [ + "workspace_id": workspace.id.uuidString, + "surface_id": remoteSurfaceID.uuidString, + ], + ]), + workspaceAliases: [:], + surfaceAliases: [remoteSurfaceID: surfaceID] + ) + let readSelectionLine = try #require(String(data: readSelection, encoding: .utf8)) + .trimmingCharacters(in: .whitespacesAndNewlines) + let parsedReadSelection: ControlRequest + switch ControlRequestParser().request(fromLine: readSelectionLine) { + case .success(let request): + parsedReadSelection = request + case .failure(let error): + Issue.record("Expected an authenticated selection request, got parse error \(error)") + return + } + let readSelectionAuthorization = TerminalController.shared + .authorizeRemoteRelayRequest(parsedReadSelection) + #expect(readSelectionAuthorization.errorResponse == nil) + #expect(readSelectionAuthorization.request.method == "surface.read_selection") + #expect( + readSelectionAuthorization.request.params["surface_id"] + == .string(surfaceID.uuidString) + ) + let forbidden = rewriter.rewriteRemoteRelayCommandLine( try requestData([ "id": "relay-forbidden", diff --git a/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift b/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift index afb9543f4aa..73ff3ca924d 100644 --- a/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift @@ -2,6 +2,7 @@ import CMUXAgentLaunch import Foundation import SQLite3 import Testing +import CMUXAgentLaunch #if canImport(cmux_DEV) @testable import cmux_DEV diff --git a/cmuxTests/SurfaceSelectionNavigationLoader.swift b/cmuxTests/SurfaceSelectionNavigationLoader.swift new file mode 100644 index 00000000000..12dd93eb828 --- /dev/null +++ b/cmuxTests/SurfaceSelectionNavigationLoader.swift @@ -0,0 +1,63 @@ +import AppKit +import Foundation +import WebKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +enum SurfaceSelectionTestError: Error { + case expectedSnapshot + case navigationFailed(String) +} + +@MainActor +final class SurfaceSelectionNavigationLoader { + private var continuation: CheckedContinuation<Void, any Error>? + private weak var navigationDelegate: BrowserNavigationDelegate? + private var previousDidFinish: ((WKWebView) -> Void)? + private var previousDidFailNavigation: ((WKWebView, String, String, WKNavigation?) -> Void)? + + func load( + _ html: String, + baseURL: URL, + in webView: WKWebView + ) async throws { + guard let navigationDelegate = webView.navigationDelegate as? BrowserNavigationDelegate else { + throw SurfaceSelectionTestError.navigationFailed("Browser navigation delegate unavailable") + } + self.navigationDelegate = navigationDelegate + previousDidFinish = navigationDelegate.didFinish + previousDidFailNavigation = navigationDelegate.didFailNavigation + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + navigationDelegate.didFinish = { [weak self, weak webView] finishedWebView in + self?.previousDidFinish?(finishedWebView) + guard finishedWebView === webView else { return } + self?.finish(with: .success(())) + } + navigationDelegate.didFailNavigation = { + [weak self, weak webView] failedWebView, failedURL, message, navigation in + self?.previousDidFailNavigation?(failedWebView, failedURL, message, navigation) + guard failedWebView === webView else { return } + self?.finish(with: .failure( + SurfaceSelectionTestError.navigationFailed(message) + )) + } + webView.loadHTMLString(html, baseURL: baseURL) + } + } + + private func finish(with result: Result<Void, any Error>) { + guard let continuation else { return } + self.continuation = nil + navigationDelegate?.didFinish = previousDidFinish + navigationDelegate?.didFailNavigation = previousDidFailNavigation + navigationDelegate = nil + previousDidFinish = nil + previousDidFailNavigation = nil + continuation.resume(with: result) + } +} diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift new file mode 100644 index 00000000000..3e380b0d19d --- /dev/null +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -0,0 +1,516 @@ +import AppKit +import Foundation +import Testing +import WebKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("Surface selection", .serialized) +struct SurfaceSelectionTests { + @Test func selectionValueTypesRejectInvalidPayloadStates() { + #expect(SurfaceSelectionLineRange(start: 0, end: 1) == nil) + #expect(SurfaceSelectionLineRange(start: 3, end: 2) == nil) + #expect(SurfaceSelectionLineRange(start: 1, end: 1) != nil) + + let snapshot = SurfaceSelectionSnapshot.none( + kind: .filePreview, + filePath: "/tmp/example.swift" + ) + #expect(!snapshot.hasSelection) + #expect(snapshot.text.isEmpty) + #expect(snapshot.lineRange == nil) + + let oversizedText = SurfaceSelectionSnapshot.boundedText( + String(repeating: "x", count: SurfaceSelectionSnapshot.maximumTextBytes + 32) + ) + #expect(oversizedText.utf8.count <= SurfaceSelectionSnapshot.maximumTextBytes) + #expect(oversizedText.hasSuffix("…")) + + let oversizedUnicode = SurfaceSelectionSnapshot.boundedText( + String(repeating: "😀", count: SurfaceSelectionSnapshot.maximumTextBytes / 4 + 32) + ) + #expect(oversizedUnicode.utf8.count <= SurfaceSelectionSnapshot.maximumTextBytes) + #expect(!oversizedUnicode.contains("\u{FFFD}")) + } + + @Test func paneRoutingSelectsItsSurfaceAndFailsClosed() throws { + let workspace = Workspace() + let panelID = try #require(workspace.focusedPanelId) + let paneID = try #require(workspace.paneId(forPanelId: panelID)?.id) + + let resolved = try #require(workspace.controlRequestedSurfaceTarget( + explicitSurfaceID: nil, + routedPaneID: paneID + )) + #expect(resolved.requestedSurfaceID == panelID) + #expect(resolved.target?.surfaceID == panelID) + #expect(workspace.controlRequestedSurfaceTarget( + explicitSurfaceID: nil, + routedPaneID: UUID() + ) == nil) + } + + @Test func nativeSelectionMapsUTF16RangesToOneBasedSourceLines() async throws { + let cases: [(source: String, selected: String, start: Int, end: Int)] = [ + ("alpha\nbeta\ngamma", "beta", 2, 2), + ("alpha\r\nbeta\r\ngamma", "beta\r\ngamma", 2, 3), + ("alpha\rbeta", "beta", 2, 2), + ("alpha\u{0085}beta", "beta", 2, 2), + ("alpha\u{2028}beta", "beta", 2, 2), + ("zero\n😀 emoji\nlast\n", "😀 emoji\nlast\n", 2, 3), + ] + + for testCase in cases { + let textView = NSTextView(frame: .zero) + textView.string = testCase.source + let selectedRange = (testCase.source as NSString).range(of: testCase.selected) + textView.setSelectedRange(selectedRange) + + let snapshot = await NativeTextSurfaceSelectionReader().read( + textView: textView, + kind: .filePreview, + filePath: "/tmp/../tmp/example.swift" + ) + + #expect(snapshot.hasSelection) + #expect(snapshot.text == testCase.selected) + #expect(snapshot.filePath == "/tmp/example.swift") + #expect(snapshot.lineRange?.start == testCase.start) + #expect(snapshot.lineRange?.end == testCase.end) + } + } + + @Test func nativePanelsExposeTheSameSelectionShape() async throws { + let source = "first\nselected 😀 text\nlast" + let selectedRange = (source as NSString).range(of: "selected 😀 text") + + let fileTextView = NSTextView(frame: .zero) + fileTextView.string = source + fileTextView.setSelectedRange(selectedRange) + let filePanel = FilePreviewPanel( + workspaceId: UUID(), + filePath: "/tmp/example.swift", + startFileWatcher: false + ) + filePanel.textView = fileTextView + defer { filePanel.close() } + + let fileSnapshot = try snapshot(from: await filePanel.readSurfaceSelection()) + #expect(fileSnapshot.kind == .filePreview) + #expect(fileSnapshot.text == "selected 😀 text") + #expect(fileSnapshot.filePath == "/tmp/example.swift") + #expect(fileSnapshot.lineRange == SurfaceSelectionLineRange(start: 2, end: 2)) + + let markdownTextView = NSTextView(frame: .zero) + markdownTextView.string = source + markdownTextView.setSelectedRange(selectedRange) + let markdownPanel = MarkdownPanel( + workspaceId: UUID(), + filePath: "/tmp/example.md" + ) + markdownPanel.setDisplayMode(.text) + markdownPanel.attachTextView(markdownTextView) + defer { markdownPanel.close() } + + let markdownSnapshot = try snapshot(from: await markdownPanel.readSurfaceSelection()) + #expect(markdownSnapshot.kind == .markdown) + #expect(markdownSnapshot.text == "selected 😀 text") + #expect(markdownSnapshot.filePath == "/tmp/example.md") + #expect(markdownSnapshot.lineRange == SurfaceSelectionLineRange(start: 2, end: 2)) + } + + @Test func browserAndMarkdownPreviewReadLiveDOMSelection() async throws { + let unloadedPanel = BrowserPanel(workspaceId: UUID()) + defer { unloadedPanel.close() } + let unloadedSnapshot = try snapshot(from: await unloadedPanel.readSurfaceSelection()) + #expect(!unloadedSnapshot.hasSelection) + #expect(unloadedSnapshot.kind == .browser) + #expect(unloadedSnapshot.url == nil) + + let panel = BrowserPanel(workspaceId: UUID()) + let contentRect = NSRect(x: 0, y: 0, width: 640, height: 480) + let window = NSWindow( + contentRect: contentRect, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + let hostView = NSView(frame: contentRect) + panel.webView.frame = hostView.bounds + panel.webView.autoresizingMask = [.width, .height] + hostView.addSubview(panel.webView) + window.contentView = hostView + window.orderFrontRegardless() + window.displayIfNeeded() + #expect(window.makeFirstResponder(panel.webView)) + defer { + panel.close() + window.orderOut(nil) + window.close() + } + let baseURL = URL(fileURLWithPath: "/tmp/cmux-selection-test/document.html") + let loader = SurfaceSelectionNavigationLoader() + try await loader.load( + """ + <!doctype html> + <html><body> + <p id="passage">before selected browser words after</p> + <iframe id="same-origin-frame" srcdoc="<p id='frame-passage'>before selected frame words after</p>"></iframe> + <textarea id="editor">before selected editable words after</textarea> + <input id="password" type="password" value="top-secret"> + </body></html> + """, + baseURL: baseURL, + in: panel.webView + ) + + let preparedBrowserSelection = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + const node = document.getElementById('passage').firstChild; + const start = node.textContent.indexOf('selected browser words'); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + 'selected browser words'.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + document.addEventListener( + 'selectionchange', + () => resolve(selection.toString()), + { once: true, capture: true } + ); + selection.addRange(range); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(preparedBrowserSelection as? String == "selected browser words") + + let browserFirstResponder = window.firstResponder + let browserSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(browserSnapshot.kind == .browser) + #expect(browserSnapshot.text == "selected browser words") + #expect(browserSnapshot.url == baseURL.absoluteString) + #expect(browserSnapshot.filePath == nil) + #expect(browserSnapshot.lineRange == nil) + #expect(window.firstResponder === browserFirstResponder) + + let markdownSnapshot = try snapshot(from: await WebSurfaceSelectionReader().read( + webView: panel.webView, + kind: .markdown, + filePath: "/tmp/guide.md" + )) + #expect(markdownSnapshot.kind == .markdown) + #expect(markdownSnapshot.text == "selected browser words") + #expect(markdownSnapshot.filePath == "/tmp/guide.md") + + let neighboringSurface = SurfaceSelectionFirstResponderView(frame: .zero) + hostView.addSubview(neighboringSurface) + #expect(window.makeFirstResponder(neighboringSurface)) + let neighboringFirstResponder = window.firstResponder + let retainedBrowserSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(retainedBrowserSnapshot.text == "selected browser words") + #expect(window.firstResponder === neighboringFirstResponder) + #expect(window.makeFirstResponder(panel.webView)) + + // WebKit can emit a collapsed selectionchange before native focus + // leaves the web view. That event is observational and must not erase + // the snapshot that an agent reads from the neighboring surface. + let simulatedFocusHandoff = try await panel.evaluateJavaScript( + """ + (() => { + window.getSelection().removeAllRanges(); + document.dispatchEvent(new Event('selectionchange')); + return window.getSelection().isCollapsed; + })() + """ + ) + #expect(simulatedFocusHandoff as? Bool == true) + let retainedAfterCollapsedSelectionChange = try snapshot( + from: await panel.readSurfaceSelection() + ) + #expect(retainedAfterCollapsedSelectionChange.text == "selected browser words") + + let clearedBrowserSelection = try await panel.evaluateJavaScript( + """ + (() => { + document.getElementById('passage').dispatchEvent( + new Event('pointerdown', { bubbles: true }) + ); + window.getSelection().removeAllRanges(); + document.dispatchEvent(new Event('selectionchange')); + return window.getSelection().isCollapsed; + })() + """ + ) + #expect(clearedBrowserSelection as? Bool == true) + let clearedBrowserSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(!clearedBrowserSnapshot.hasSelection) + #expect(clearedBrowserSnapshot.text.isEmpty) + + let preparedFrameSelection = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + window.getSelection().removeAllRanges(); + const frame = document.getElementById('same-origin-frame'); + const childWindow = frame.contentWindow; + const node = childWindow.document.getElementById('frame-passage').firstChild; + const start = node.textContent.indexOf('selected frame words'); + const range = childWindow.document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + 'selected frame words'.length); + const selection = childWindow.getSelection(); + selection.removeAllRanges(); + childWindow.document.addEventListener( + 'selectionchange', + () => { + childWindow.focus(); + resolve(`${document.activeElement === frame}|${selection.toString()}`); + }, + { once: true, capture: true } + ); + selection.addRange(range); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(preparedFrameSelection as? String == "true|selected frame words") + let frameSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(frameSnapshot.text == "selected frame words") + + let preparedEditableSelection = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + const editor = document.getElementById('editor'); + const start = editor.value.indexOf('selected editable words'); + editor.focus(); + editor.addEventListener( + 'select', + () => { + const selected = editor.value.slice( + editor.selectionStart, + editor.selectionEnd + ); + resolve(`${document.activeElement === editor}|${selected}`); + }, + { once: true, capture: true } + ); + editor.setSelectionRange(start, start + 'selected editable words'.length); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(preparedEditableSelection as? String == "true|selected editable words") + let editableSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(editableSnapshot.hasSelection) + #expect(editableSnapshot.text == "selected editable words") + + _ = try await panel.evaluateJavaScript( + """ + (() => { + window.getSelection().removeAllRanges(); + const password = document.getElementById('password'); + password.focus(); + password.setSelectionRange(0, password.value.length); + password.dispatchEvent(new Event('select', { bubbles: true })); + return true; + })() + """ + ) + let bridgeRemainsImmutable = try await panel.evaluateJavaScript( + """ + (() => { + const runtime = globalThis.__cmuxSurfaceSelectionRuntime; + const read = runtime?.read; + try { runtime.read = () => ({ has_selection: true, text: 'leaked' }); } catch (_) {} + try { globalThis.__cmuxSurfaceSelectionRuntime = { read }; } catch (_) {} + return runtime?.read === read && globalThis.__cmuxSurfaceSelectionRuntime === runtime; + })() + """ + ) + #expect(bridgeRemainsImmutable as? Bool == true) + let passwordSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(!passwordSnapshot.hasSelection) + #expect(passwordSnapshot.text.isEmpty) + #expect(passwordSnapshot.url == baseURL.absoluteString) + + let preparedNavigationSelection = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + document.activeElement?.blur(); + const node = document.getElementById('passage').firstChild; + const selectedText = 'before selected browser words'; + const start = node.textContent.indexOf(selectedText); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + selectedText.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + document.addEventListener( + 'selectionchange', + () => resolve(selection.toString()), + { once: true, capture: true } + ); + selection.addRange(range); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(preparedNavigationSelection as? String == "before selected browser words") + + _ = try await panel.evaluateJavaScript( + """ + (() => { + history.pushState({ cmuxSelectionTest: true }, '', '#selection-route'); + return location.href; + })() + """ + ) + let navigationSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(!navigationSnapshot.hasSelection) + #expect(navigationSnapshot.text.isEmpty) + + _ = try await panel.evaluateJavaScript( + "history.replaceState({}, '', '\(baseURL.absoluteString)')" + ) + let preparedMutationSelection = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + document.activeElement?.blur(); + const node = document.getElementById('passage').firstChild; + const selectedText = 'before selected browser words'; + const start = node.textContent.indexOf(selectedText); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + selectedText.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + document.addEventListener( + 'selectionchange', + () => resolve(selection.toString()), + { once: true, capture: true } + ); + selection.addRange(range); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(preparedMutationSelection as? String == "before selected browser words") + + let unrelatedMutationObserved = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + const observer = new MutationObserver(() => { + observer.disconnect(); + resolve(true); + }); + observer.observe(document.body, { childList: true, subtree: true }); + const status = document.createElement('span'); + status.textContent = 'unrelated page status'; + document.body.appendChild(status); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(unrelatedMutationObserved as? Bool == true) + let retainedAfterUnrelatedMutation = try snapshot(from: await panel.readSurfaceSelection()) + #expect(retainedAfterUnrelatedMutation.hasSelection) + #expect(retainedAfterUnrelatedMutation.text == "before selected browser words") + + let mutationObserved = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + const passage = document.getElementById('passage'); + const observer = new MutationObserver(() => { + observer.disconnect(); + resolve(true); + }); + observer.observe(passage, { childList: true, characterData: true, subtree: true }); + passage.firstChild.nodeValue = 'after the document content changed'; + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(mutationObserved as? Bool == true) + let mutationSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(!mutationSnapshot.hasSelection) + #expect(mutationSnapshot.text.isEmpty) + + let preparedProgrammaticClear = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + const node = document.getElementById('passage').firstChild; + node.nodeValue = 'before selected browser words'; + const selectedText = 'selected browser words'; + const start = node.textContent.indexOf(selectedText); + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, start + selectedText.length); + const selection = window.getSelection(); + selection.removeAllRanges(); + document.addEventListener( + 'selectionchange', + () => resolve(selection.toString()), + { once: true, capture: true } + ); + selection.addRange(range); + }); + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + #expect(preparedProgrammaticClear as? String == "selected browser words") + _ = try await panel.evaluateJavaScript( + """ + (() => { + document.getElementById('passage').dispatchEvent( + new Event('pointerdown', { bubbles: true }) + ); + window.getSelection().removeAllRanges(); + document.dispatchEvent(new Event('selectionchange')); + return true; + })() + """ + ) + let programmaticClearSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(!programmaticClearSnapshot.hasSelection) + #expect(programmaticClearSnapshot.text.isEmpty) + + _ = try await panel.evaluateJavaScript("(() => { const nodes = Array.from({ length: 4100 }, () => document.createTextNode('x')); document.body.replaceChildren(...nodes); const range = document.createRange(); range.setStart(nodes[0], 0); range.setEnd(nodes[nodes.length - 1], 1); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); return true; })()") + #expect(await panel.readSurfaceSelection() == .unavailable) + } + + private func snapshot( + from result: SurfaceSelectionReadResult + ) throws -> SurfaceSelectionSnapshot { + guard case .snapshot(let snapshot) = result else { + throw SurfaceSelectionTestError.expectedSnapshot + } + return snapshot + } +} + +private final class SurfaceSelectionFirstResponderView: NSView { + override var acceptsFirstResponder: Bool { true } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index a7ad8f5bf54..59fe569e57c 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -135,6 +135,28 @@ private func XCTFail( Issue.record(Comment(rawValue: message()), sourceLocation: sourceLocation) } +@MainActor +private final class SocketSurfaceSelectionPanel: Panel { + let id = UUID() + let stableSurfaceIdentity = PanelStableSurfaceIdentity() + let panelType: PanelType = .filePreview + let displayTitle = "Selection fixture" + let selection: SurfaceSelectionSnapshot + + init(selection: SurfaceSelectionSnapshot) { + self.selection = selection + } + + func close() {} + func focus() {} + func unfocus() {} + func triggerFlash(reason: WorkspaceAttentionFlashReason) {} + + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + .snapshot(selection) + } +} + @MainActor @Suite(.serialized) final class TerminalControllerSocketSecurityTests { @@ -906,6 +928,166 @@ final class TerminalControllerSocketSecurityTests { XCTAssertEqual(v1Replies, ["ERROR: Terminal surface not found"]) } + @Test func testSurfaceReadSelectionIsDiscoverableAndServicedOnTheWorkerLane() async throws { + let socketPath = makeSocketPath("v2-read-selection-worker") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + let panel = try XCTUnwrap(workspace.focusedTerminalPanel) + panel.surface.releaseSurfaceForTesting() + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let capabilitiesEnvelope = try await sendV2RequestAsync( + method: "system.capabilities", + params: [:], + to: socketPath + ) + let capabilities = try XCTUnwrap(capabilitiesEnvelope["result"] as? [String: Any]) + let methods = try XCTUnwrap(capabilities["methods"] as? [String]) + XCTAssertTrue(methods.contains("surface.read_selection")) + + let inline = TerminalController.shared.handleSocketLine( + #"{"id":"rs-main","method":"surface.read_selection","params":{}}"# + ) + XCTAssertTrue(inline.contains("invalid_dispatch"), inline) + XCTAssertTrue(inline.contains("surface.read_selection must run off the main thread"), inline) + + let envelope = try await sendV2RequestAsync( + method: "surface.read_selection", + params: ["workspace_id": workspace.id.uuidString], + to: socketPath + ) + XCTAssertEqual(envelope["ok"] as? Bool, false) + let error = try XCTUnwrap(envelope["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, "unavailable") + XCTAssertEqual(error["message"] as? String, "Selection reading is currently unavailable.") + } + + @Test func testSurfaceReadSelectionRoutesAnyPanelAndReturnsTheCommonShape() async throws { + let socketPath = makeSocketPath("v2-read-selection-shape") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + let panel = SocketSurfaceSelectionPanel(selection: .selected( + kind: .filePreview, + text: "let answer = 42", + filePath: "/tmp/Answer.swift", + lineRange: SurfaceSelectionLineRange(start: 7, end: 7) + )) + workspace.panels[panel.id] = panel + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + let envelope = try await sendV2RequestAsync( + method: "surface.read_selection", + params: [ + "workspace_id": workspace.id.uuidString, + "surface_id": panel.id.uuidString, + ], + to: socketPath + ) + XCTAssertEqual(envelope["ok"] as? Bool, true) + let result = try XCTUnwrap(envelope["result"] as? [String: Any]) + XCTAssertEqual(result["has_selection"] as? Bool, true) + XCTAssertEqual(result["kind"] as? String, "filepreview") + XCTAssertEqual(result["text"] as? String, "let answer = 42") + XCTAssertEqual(result["file_path"] as? String, "/tmp/Answer.swift") + XCTAssertEqual(result["workspace_id"] as? String, workspace.id.uuidString) + XCTAssertEqual(result["surface_id"] as? String, panel.id.uuidString) + let lineRange = try XCTUnwrap(result["line_range"] as? [String: Any]) + XCTAssertEqual((lineRange["start"] as? NSNumber)?.intValue, 7) + XCTAssertEqual((lineRange["end"] as? NSNumber)?.intValue, 7) + let encoded = try XCTUnwrap(result["base64"] as? String) + let decoded = try XCTUnwrap(Data(base64Encoded: encoded)) + XCTAssertEqual(String(decoding: decoded, as: UTF8.self), "let answer = 42") + } + + @Test func testSurfaceReadSelectionFailsClosedForExplicitSelectors() async throws { + let socketPath = makeSocketPath("v2-read-selection-invalid-selector") + let manager = TabManager() + let workspace = manager.addWorkspace(select: true) + defer { + if manager.tabs.contains(where: { $0.id == workspace.id }) { + manager.closeWorkspace(workspace) + } + } + + TerminalController.shared.start( + tabManager: manager, + socketPath: socketPath, + accessMode: .allowAll + ) + try waitForSocket(at: socketPath) + + for selector in [ + "window_id", + "group_id", + "workspace_id", + "surface_id", + "terminal_id", + "tab_id", + "pane_id", + ] { + let malformedEnvelope = try await sendV2RequestAsync( + method: "surface.read_selection", + params: [selector: "not-a-selector"], + to: socketPath + ) + XCTAssertEqual(malformedEnvelope["ok"] as? Bool, false, selector) + let malformedError = try XCTUnwrap( + malformedEnvelope["error"] as? [String: Any], + selector + ) + XCTAssertEqual( + malformedError["code"] as? String, + "invalid_params", + selector + ) + XCTAssertEqual( + malformedError["message"] as? String, + "Invalid selector for `\(selector)`.", + selector + ) + let malformedData = try XCTUnwrap( + malformedError["data"] as? [String: Any], + selector + ) + XCTAssertEqual(malformedData["selector"] as? String, selector) + } + + let missingEnvelope = try await sendV2RequestAsync( + method: "surface.read_selection", + params: [ + "workspace_id": workspace.id.uuidString, + "pane_id": UUID().uuidString, + ], + to: socketPath + ) + XCTAssertEqual(missingEnvelope["ok"] as? Bool, false) + let missingError = try XCTUnwrap(missingEnvelope["error"] as? [String: Any]) + XCTAssertEqual(missingError["code"] as? String, "not_found") + XCTAssertEqual(missingError["message"] as? String, "No surface is focused.") + } + @Test func testV1SetStatusIsServicedOnWorkerLaneWhileMainThreadIsBlocked() throws { let socketPath = makeSocketPath("v1-status-worker") let manager = TabManager() diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 02cf60c34b6..a71c7542a5c 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -138,7 +138,8 @@ Environment: | `select-workspace` | Select a workspace. | | `rename-workspace`, `rename-window` | Rename a workspace. `rename-window` is a compatibility alias. | | `current-workspace` | Print current workspace information. | -| `read-screen` | Read terminal text from a surface. | +| `read-selection` | Read the active selection from a terminal, file preview, Markdown, or browser surface. Plain output includes available source context; `--json` returns the complete socket response. | +| `read-screen` | Read terminal text from a surface. `--selection` is a text-only compatibility alias for `read-selection`. | | `send` | Send text to a terminal surface. | | `send-key` | Send one key to a terminal surface. | | `send-panel` | Send text to a panel/surface. | @@ -180,6 +181,73 @@ Environment: | `ssh-session-end` | Internal helper that clears remote SSH session state. | | `__tmux-compat` | Internal tmux compatibility dispatcher. | +## Surface Selection Contract + +`surface.read_selection` is a v2 worker-lane socket method advertised by +`system.capabilities` and printed by `cmux capabilities`. It accepts the usual +surface routing selectors (`window_id`, `workspace_id`, `surface_id`, +`terminal_id`, `tab_id`, and `pane_id`) without focusing a window, workspace, +pane, or surface. + +Successful responses use one shape across surface kinds: + +```json +{ + "has_selection": true, + "kind": "filepreview", + "text": "let answer = 42", + "base64": "bGV0IGFuc3dlciA9IDQy", + "file_path": "/Users/me/project/Answer.swift", + "line_range": { "start": 7, "end": 7 }, + "workspace_id": "...", + "workspace_ref": "workspace:1", + "surface_id": "...", + "surface_ref": "surface:2", + "window_id": "...", + "window_ref": "window:1" +} +``` + +- `has_selection`, `kind`, `text`, and `base64` are always present. +- Selection text is capped at 1 MiB before it crosses the socket boundary; + browser and native text selections are shortened with a visible ellipsis, + while a terminal selection that exceeds Ghostty's bounded work budget is + reported as temporarily unavailable. +- `file_path` is present for native file/Markdown selections and Markdown + preview selections. +- `line_range` is present when a native text view can map the selection back to + source lines. `start` and `end` are one-based and inclusive. Selecting a line + terminator keeps that terminator on its source line. +- `url` is present for browser selections. +- The normal workspace, surface, and window identity fields are always emitted; + absent window identity values are JSON `null`. +- A supported surface with no active selection succeeds with + `has_selection: false`, empty `text`, and empty `base64`. Unsupported surface + kinds return `not_supported`; a selectable surface whose live view is no + longer available returns `unavailable`. + +Terminal selections come from Ghostty's live selection API. Text file previews +and Markdown text mode read their native text view. Markdown preview and browser +surfaces read the page selection, including editable text controls; password +input selections are never exposed. Non-text file preview modes do not claim +selection support. + +`cmux read-selection` prints available kind, file, line, or URL context followed +by the selected text. A supported surface with no selection still exits zero and +prints the explicit `Has selection: false` marker. `cmux read-selection --json` +preserves the complete response for scripts. `cmux read-screen --selection` +uses the same socket path but omits source metadata, and cannot be combined with +`--scrollback` or `--lines`. + +Examples: + +```bash +cmux read-selection --surface surface:2 +cmux read-selection --surface surface:2 --json +cmux read-screen --surface surface:2 --selection +cmux rpc surface.read_selection '{"surface_id":"83F4E6A4-5246-4DB8-A412-9CE7B059FA6C"}' +``` + ## Command Families Sessions output: