From 5269a5a949e5661ce53386e2169311de9c2706cf Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 11 Aug 2026 17:38:46 -0700 Subject: [PATCH 01/44] Test unified surface selection reads --- .../ControlCommandExecutionPolicyTests.swift | 1 + .../CLIExplicitSurfaceRoutingTests.swift | 12 +++++ ...erminalControllerSocketSecurityTests.swift | 45 +++++++++++++++++++ 3 files changed, 58 insertions(+) diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index 4b094905fe6..3b15edcabdc 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -153,6 +153,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/cmuxTests/CLIExplicitSurfaceRoutingTests.swift b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift index 383b72b4ae4..7910734e3e1 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", @@ -343,6 +347,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: diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 7f15e0583a7..1fddd2fbb30 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -846,6 +846,51 @@ 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, "Failed to read terminal selection") + } + @Test func testV1SetStatusIsServicedOnWorkerLaneWhileMainThreadIsBlocked() throws { let socketPath = makeSocketPath("v1-status-worker") let manager = TabManager() From 932782be9f3fda2952c0118c29fab1031b81465a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 11 Aug 2026 19:54:14 -0700 Subject: [PATCH 02/44] Implement unified surface selection reads --- CLI/CMUXCLI+CommandSuggestions.swift | 1 + CLI/CMUXCLI+SurfaceSelection.swift | 212 ++++++++++++++++ CLI/cmux.swift | 58 +++-- .../Wire/ControlCommandExecutionPolicy.swift | 4 + Resources/Localizable.xcstrings | 126 +++++++++ Sources/Panels/BrowserPanel.swift | 13 + Sources/Panels/FilePreviewPanel.swift | 9 + Sources/Panels/MarkdownPanel.swift | 13 + Sources/Panels/MarkdownWebRenderer.swift | 11 + Sources/Panels/MarkdownWebSupport.swift | 4 + .../NativeTextSurfaceSelectionReader.swift | 56 ++++ Sources/Panels/Panel.swift | 7 + .../Panels/SurfaceSelectionLineRange.swift | 12 + .../Panels/SurfaceSelectionReadResult.swift | 8 + Sources/Panels/SurfaceSelectionSnapshot.swift | 59 +++++ Sources/Panels/TerminalPanel.swift | 26 ++ .../Panels/WebSurfaceSelectionReader.swift | 104 ++++++++ Sources/SurfaceSelectionSocketCapture.swift | 12 + ...SurfaceSelectionSocketCaptureOutcome.swift | 7 + Sources/SurfaceSelectionSocketFailure.swift | 8 + .../TerminalController+SurfaceSelection.swift | 236 +++++++++++++++++ Sources/TerminalController.swift | 11 +- cmux.xcodeproj/project.pbxproj | 44 ++++ .../CLIExplicitSurfaceRoutingTests.swift | 145 +++++++++++ cmuxTests/SurfaceSelectionTests.swift | 240 ++++++++++++++++++ ...erminalControllerSocketSecurityTests.swift | 70 +++++ docs/cli-contract.md | 66 ++++- 27 files changed, 1543 insertions(+), 19 deletions(-) create mode 100644 CLI/CMUXCLI+SurfaceSelection.swift create mode 100644 Sources/Panels/NativeTextSurfaceSelectionReader.swift create mode 100644 Sources/Panels/SurfaceSelectionLineRange.swift create mode 100644 Sources/Panels/SurfaceSelectionReadResult.swift create mode 100644 Sources/Panels/SurfaceSelectionSnapshot.swift create mode 100644 Sources/Panels/WebSurfaceSelectionReader.swift create mode 100644 Sources/SurfaceSelectionSocketCapture.swift create mode 100644 Sources/SurfaceSelectionSocketCaptureOutcome.swift create mode 100644 Sources/SurfaceSelectionSocketFailure.swift create mode 100644 Sources/TerminalController+SurfaceSelection.swift create mode 100644 cmuxTests/SurfaceSelectionTests.swift diff --git a/CLI/CMUXCLI+CommandSuggestions.swift b/CLI/CMUXCLI+CommandSuggestions.swift index 4f92479744a..fd4ce108f2c 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..adbf2c9778e --- /dev/null +++ b/CLI/CMUXCLI+SurfaceSelection.swift @@ -0,0 +1,212 @@ +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 { + throw CLIError(message: String( + format: String( + localized: "cli.readSelection.error.noActiveSelection", + defaultValue: "%@: no active selection" + ), + commandName + )) + } + + 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 474288da798..86f4a2a3a75 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3410,6 +3410,9 @@ struct CMUXCLI { if normalizedCommand == "restore" { return false } + if normalizedCommand == "read-screen" || normalizedCommand == "read-selection" { + return false + } if normalizedCommand == "surface", commandArgs.first?.lowercased() == "resume" { return false } @@ -5259,7 +5262,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") @@ -17157,23 +17193,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] [--] @@ -36637,7 +36660,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/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index c6aeaae69bd..df5b5f5be9f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -120,6 +120,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", // `workspace.env` is a read that resolves a workspace and copies its // env dictionary behind a `v2MainSync` hop, so it runs on the worker // lane like the other workspace reads below. diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 42cefeee6f8..b52e25fe3c5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -1,6 +1,132 @@ { "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.error.noActiveSelection": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "%@: no active selection" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@: アクティブな選択範囲がありません" } } + } + }, + "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.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": "TabManager is not available." } }, + "ja": { "stringUnit": { "state": "translated", "value": "TabManager を利用できません。" } } + } + }, + "socket.surfaceSelection.unavailable": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Failed to read %@ selection" } }, + "ja": { "stringUnit": { "state": "translated", "value": "%@ の選択範囲を読み取れませんでした" } } + } + }, + "socket.surfaceSelection.unsupported": { + "extractionState": "manual", + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Surface kind `%@` 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": "ワークスペースが見つかりません。" } } + } + }, "cli.help.notify.reply": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 18637b7f15e..a551b8d9a6c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -6833,6 +6833,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 WebSurfaceSelectionReader().read( + webView: webView, + kind: .browser, + url: url + ) + } + // MARK: - Find in Page func startFind() { diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index 14e369dac19..5be43cc7fd6 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1074,6 +1074,15 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan focusCoordinator.unregisterAll() } + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + guard previewMode == .text else { return .unsupported } + return .snapshot(NativeTextSurfaceSelectionReader().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 56ad9588ffb..e4c5c398f49 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -268,6 +268,19 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel } } + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + switch displayMode { + case .text: + return .snapshot(NativeTextSurfaceSelectionReader().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 aae3b1ec40e..2a5da2aa6e6 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -340,6 +340,17 @@ struct MarkdownWebRenderer: NSViewRepresentable { return await evaluateString("window.__cmuxRenderedText && window.__cmuxRenderedText()") } + func readSurfaceSelection(filePath: String) async -> SurfaceSelectionReadResult { + guard isLoaded, let webView else { + return .snapshot(.none(kind: .markdown, filePath: filePath)) + } + return await WebSurfaceSelectionReader().read( + webView: webView, + kind: .markdown, + filePath: filePath + ) + } + 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 b16089063ba..6ea2771855e 100644 --- a/Sources/Panels/MarkdownWebSupport.swift +++ b/Sources/Panels/MarkdownWebSupport.swift @@ -246,6 +246,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..0a140636272 --- /dev/null +++ b/Sources/Panels/NativeTextSurfaceSelectionReader.swift @@ -0,0 +1,56 @@ +import AppKit +import Foundation + +/// Maps an AppKit UTF-16 selection to source text and one-based line numbers. +nonisolated struct NativeTextSurfaceSelectionReader { + @MainActor + func read( + textView: NSTextView?, + kind: PanelType, + filePath: String + ) -> SurfaceSelectionSnapshot { + let normalizedPath = URL(fileURLWithPath: filePath).standardizedFileURL.path + guard let textView else { + return .none(kind: kind, filePath: normalizedPath) + } + + let source = textView.string as NSString + let selectedRange = textView.selectedRange() + guard selectedRange.location != NSNotFound, + selectedRange.length > 0, + selectedRange.location <= source.length, + selectedRange.length <= source.length - selectedRange.location else { + return .none(kind: kind, filePath: normalizedPath) + } + + let startLine = lineNumber(atUTF16Offset: selectedRange.location, in: source) + let lastSelectedOffset = NSMaxRange(selectedRange) - 1 + let endLine = lineNumber(atUTF16Offset: lastSelectedOffset, in: source) + return .selected( + kind: kind, + text: source.substring(with: selectedRange), + filePath: normalizedPath, + lineRange: SurfaceSelectionLineRange(start: startLine, end: endLine) + ) + } + + private func lineNumber(atUTF16Offset offset: Int, in source: NSString) -> Int { + guard offset > 0 else { return 1 } + + var lineNumber = 1 + var cursor = 0 + while cursor < offset { + var lineEnd = 0 + source.getLineStart( + nil, + end: &lineEnd, + contentsEnd: nil, + for: NSRange(location: cursor, length: 0) + ) + guard lineEnd > cursor, lineEnd <= offset else { break } + lineNumber += 1 + cursor = lineEnd + } + return lineNumber + } +} diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index 949705ab227..cb19653e64c 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,10 @@ extension Panel { public var displayIcon: String? { nil } public var isDirty: Bool { false } + 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..e8aab2372de --- /dev/null +++ b/Sources/Panels/SurfaceSelectionLineRange.swift @@ -0,0 +1,12 @@ +import Foundation + +/// One-based inclusive line range for selected native text. +public nonisolated struct SurfaceSelectionLineRange: Equatable, Sendable { + public let start: Int + public let end: Int + + public init(start: Int, end: Int) { + self.start = start + self.end = end + } +} diff --git a/Sources/Panels/SurfaceSelectionReadResult.swift b/Sources/Panels/SurfaceSelectionReadResult.swift new file mode 100644 index 00000000000..034bcea63e0 --- /dev/null +++ b/Sources/Panels/SurfaceSelectionReadResult.swift @@ -0,0 +1,8 @@ +import Foundation + +/// A panel's supported, unsupported, or temporarily unavailable selection state. +public nonisolated enum SurfaceSelectionReadResult: Equatable, Sendable { + case snapshot(SurfaceSelectionSnapshot) + case unsupported + case unavailable +} diff --git a/Sources/Panels/SurfaceSelectionSnapshot.swift b/Sources/Panels/SurfaceSelectionSnapshot.swift new file mode 100644 index 00000000000..1789b48d4f7 --- /dev/null +++ b/Sources/Panels/SurfaceSelectionSnapshot.swift @@ -0,0 +1,59 @@ +import Foundation + +/// Immutable selection context shared by every selectable panel kind. +public nonisolated struct SurfaceSelectionSnapshot: Equatable, Sendable { + public let hasSelection: Bool + public let kind: PanelType + public let text: String + public let filePath: String? + public let lineRange: SurfaceSelectionLineRange? + public let url: String? + + public init( + hasSelection: Bool, + kind: PanelType, + text: String, + filePath: String?, + lineRange: SurfaceSelectionLineRange?, + url: String? + ) { + self.hasSelection = hasSelection + self.kind = kind + self.text = text + self.filePath = filePath + self.lineRange = lineRange + self.url = url + } + + public static func selected( + kind: PanelType, + text: String, + filePath: String? = nil, + lineRange: SurfaceSelectionLineRange? = nil, + url: String? = nil + ) -> Self { + Self( + hasSelection: true, + kind: kind, + text: text, + filePath: filePath, + lineRange: lineRange, + url: url + ) + } + + public static func none( + kind: PanelType, + filePath: String? = nil, + url: String? = nil + ) -> Self { + Self( + hasSelection: false, + kind: kind, + text: "", + filePath: filePath, + lineRange: nil, + url: url + ) + } +} diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 37b85148380..2f68eb8d8dd 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. @@ -110,6 +111,31 @@ final class TerminalPanel: Panel, ObservableObject { "terminal.fill" } + func readSurfaceSelection() async -> SurfaceSelectionReadResult { + guard let liveSurface = surface.liveSurfaceForGhosttyAccess( + reason: "readSurfaceSelection" + ) else { + return .unavailable + } + + var selection = ghostty_text_s() + guard ghostty_surface_read_selection(liveSurface, &selection) else { + return .snapshot(.none(kind: .terminal)) + } + defer { ghostty_surface_free_text(liveSurface, &selection) } + + let text: String + if let bytes = selection.text, selection.text_len > 0 { + text = String( + decoding: Data(bytes: bytes, count: Int(selection.text_len)), + as: UTF8.self + ) + } else { + text = "" + } + return .snapshot(.selected(kind: .terminal, text: text)) + } + func updateShellActivityState(_ state: PanelShellActivityState) { if shellActivity.state != state { shellActivity.state = state diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift new file mode 100644 index 00000000000..cc2fdfb9b8a --- /dev/null +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -0,0 +1,104 @@ +import Foundation +import WebKit + +/// Reads DOM or editable-control selection without changing focus or page state. +nonisolated struct WebSurfaceSelectionReader { + private nonisolated struct Payload: Decodable { + let hasSelection: Bool + let text: String + + enum CodingKeys: String, CodingKey { + case hasSelection = "has_selection" + case text + } + } + + private static let script = """ + (() => { + const empty = () => ({ has_selection: false, text: '' }); + const readSelection = (targetWindow) => { + let targetDocument; + try { + targetDocument = targetWindow.document; + } catch (_) { + return empty(); + } + + const active = targetDocument.activeElement; + const activeTag = String(active?.tagName || '').toLowerCase(); + if (activeTag === 'iframe' || activeTag === 'frame') { + try { + const childWindow = active.contentWindow; + return childWindow ? readSelection(childWindow) : empty(); + } catch (_) { + return empty(); + } + } + + const isInput = activeTag === 'input'; + const isTextControl = isInput || activeTag === 'textarea'; + const isPassword = isInput && String(active.type || '').toLowerCase() === 'password'; + if (isPassword) { + return empty(); + } + if (isTextControl && + typeof active.selectionStart === 'number' && + typeof active.selectionEnd === 'number' && + active.selectionEnd > active.selectionStart) { + return { + has_selection: true, + text: String(active.value || '').slice(active.selectionStart, active.selectionEnd) + }; + } + + const selection = targetWindow.getSelection(); + const hasSelection = !!selection && selection.rangeCount > 0 && !selection.isCollapsed; + return { + has_selection: hasSelection, + text: hasSelection ? selection.toString() : '' + }; + }; + + return JSON.stringify(readSelection(window)); + })() + """ + + @MainActor + func read( + webView: WKWebView, + kind: PanelType, + filePath: String? = nil, + url: String? = nil + ) async -> SurfaceSelectionReadResult { + do { + guard let encoded = try await webView.evaluateJavaScript( + Self.script, + contentWorld: .page + ) as? String, + let data = encoded.data(using: .utf8) else { + return .unavailable + } + let payload = try JSONDecoder().decode(Payload.self, from: data) + let normalizedPath = filePath.map { + URL(fileURLWithPath: $0).standardizedFileURL.path + } + if payload.hasSelection { + return .snapshot(.selected( + kind: kind, + text: payload.text, + filePath: normalizedPath, + url: url + )) + } + return .snapshot(.none( + kind: kind, + filePath: normalizedPath, + url: url + )) + } catch is CancellationError { + return .unavailable + } 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+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift new file mode 100644 index 00000000000..4339d094431 --- /dev/null +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -0,0 +1,236 @@ +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. + 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) + } + } + + 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 + )) + } + + v2RefreshKnownRefs() + let params = typedParams.mapValues(\.foundationObject) + 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: "TabManager is not available." + ) + ) + } + + 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 + } + + let snapshot: SurfaceSelectionSnapshot + switch await panel.readSurfaceSelection() { + case .snapshot(let value): + snapshot = value + case .unsupported: + return failure( + code: "not_supported", + message: String( + format: String( + localized: "socket.surfaceSelection.unsupported", + defaultValue: "Surface kind `%@` does not support selection reads." + ), + panel.panelType.rawValue + ), + data: [ + "surface_id": surfaceID.uuidString, + "kind": panel.panelType.rawValue, + ] + ) + case .unavailable: + return failure( + code: "unavailable", + message: String( + format: String( + localized: "socket.surfaceSelection.unavailable", + defaultValue: "Failed to read %@ selection" + ), + panel.panelType.rawValue + ), + data: [ + "surface_id": surfaceID.uuidString, + "kind": panel.panelType.rawValue, + ] + ) + } + + 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 acd93aece50..6cb917f3603 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1087,6 +1087,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, @@ -2764,6 +2772,7 @@ class TerminalController { "surface.report_shell_state", "surface.ports_kick", "surface.read_text", + "surface.read_selection", "surface.clear_history", "surface.trigger_flash", "pane.list", @@ -3778,7 +3787,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) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 26d19cd278c..947a5a2c292 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -711,6 +711,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C77070000000000000000008 /* CMUXCLI+SSHPTYAttachBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C77070000000000000000009 /* CMUXCLI+SSHPTYAttachBridge.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 */; }; @@ -1472,6 +1473,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 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 */; }; C0DE77060000000000000003 /* NewWorkspaceMenuModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE77060000000000000004 /* NewWorkspaceMenuModel.swift */; }; A5FB1400 /* NewWorkspaceMenuModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FB1401 /* NewWorkspaceMenuModelTests.swift */; }; @@ -2188,6 +2190,13 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 7989B0037989B0037989B003 /* SurfaceResumeRemoteContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.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 */; }; + 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 */; }; C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73B40000000000000001 /* SurfaceTabBarButtonConfiguration.swift */; }; F2100000000000000000000F /* SyntheticKeyEventFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1100000000000000000000F /* SyntheticKeyEventFactory.swift */; }; A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7206A020000000000000001 /* SystemAppearanceObserver.swift */; }; @@ -2316,6 +2325,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C79470010000000000000003 /* TerminalController+SocketClientCapability.swift in Sources */ = {isa = PBXBuildFile; fileRef = C79470010000000000000004 /* TerminalController+SocketClientCapability.swift */; }; 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 */; }; + 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 */; }; @@ -2549,6 +2559,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */; }; C37800000000000000000003 /* VMDefaultCloudCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */; }; C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000002 /* VMSSHCommandTests.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 */; }; @@ -3532,6 +3543,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C77070000000000000000009 /* CMUXCLI+SSHPTYAttachBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+SSHPTYAttachBridge.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>"; }; @@ -4230,6 +4242,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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>"; }; C0DE77060000000000000004 /* NewWorkspaceMenuModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWorkspaceMenuModel.swift; sourceTree = "<group>"; }; A5FB1401 /* NewWorkspaceMenuModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewWorkspaceMenuModelTests.swift; sourceTree = "<group>"; }; @@ -4932,6 +4945,13 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 7989B1037989B1037989B103 /* SurfaceResumeRemoteContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceResumeRemoteContext.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>"; }; + 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>"; }; 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>"; }; A7206A020000000000000001 /* SystemAppearanceObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemAppearanceObserver.swift; sourceTree = "<group>"; }; @@ -5060,6 +5080,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C79470010000000000000004 /* TerminalController+SocketClientCapability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+SocketClientCapability.swift"; sourceTree = "<group>"; }; 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>"; }; + 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>"; }; @@ -5293,6 +5314,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMClientSocketCommands.swift; sourceTree = "<group>"; }; C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDefaultCloudCommandTests.swift; sourceTree = "<group>"; }; C37800000000000000000002 /* VMSSHCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSSHCommandTests.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>"; }; @@ -6634,6 +6656,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, @@ -6965,6 +6991,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8561B0058561B0058561B005 /* GlobalSearchKeyEvent+QueryEditing.swift */, 3865B0033865B0033865B003 /* MenubarSearchPopover.swift */, A5001410 /* Panel.swift */, + A10020000000000000000010 /* NativeTextSurfaceSelectionReader.swift */, + A10020000000000000000002 /* SurfaceSelectionLineRange.swift */, + A1002000000000000000000E /* SurfaceSelectionReadResult.swift */, + A1002000000000000000000C /* SurfaceSelectionSnapshot.swift */, + A10020000000000000000012 /* WebSurfaceSelectionReader.swift */, A85630010000000000000001 /* WorkspaceAttentionColor.swift */, C54860060000000000000002 /* PanelStableSurfaceIdentity.swift */, A5F0C0010000000000000001 /* WorkspaceAttentionFlashRingView.swift */, @@ -7663,6 +7694,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, @@ -8057,6 +8089,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE31390000000000000112 /* CMUXOpenHTMLFocusTests.swift */, C0DE75890000000000000004 /* DeflatedAssetTestSupport.swift */, 1590EE4F01FEF02D40A48698 /* CLIExplicitSurfaceRoutingTests.swift */, + A1002000000000000000000A /* SurfaceSelectionTests.swift */, A7367002A1B2C3D4E5F60718 /* CLISSHSessionAttachAnchorTests.swift */, C0DEBACC0000000000000004 /* CodexTeamsAppServerFixture.swift */, C0DEBACC0000000000000006 /* CodexTeamsResumedBackfillTests.swift */, @@ -9745,6 +9778,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C75740020000000000000001 /* MouseDownMenuItemView.swift in Sources */, 596100000000000000000003 /* NativeNotificationDeliveryHooks.swift in Sources */, A6AC72030000000000000001 /* NativeSpinner.swift in Sources */, + A1002000000000000000000F /* NativeTextSurfaceSelectionReader.swift in Sources */, C0DE77060000000000000003 /* NewWorkspaceMenuModel.swift in Sources */, C06552010000000000000003 /* NotificationBurstCoalescer.swift in Sources */, 7490C00D7490C00D7490C00D /* NotificationCacheMemoryPressureResponder.swift in Sources */, @@ -10216,6 +10250,12 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 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 */, C51A73B40000000000000002 /* SurfaceTabBarButtonConfiguration.swift in Sources */, F2100000000000000000000F /* SyntheticKeyEventFactory.swift in Sources */, A7206A010000000000000001 /* SystemAppearanceObserver.swift in Sources */, @@ -10328,6 +10368,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C79470010000000000000003 /* TerminalController+SocketClientCapability.swift in Sources */, A79840030000000000000002 /* TerminalController+SocketConfiguration.swift in Sources */, C79470020000000000000001 /* TerminalController+SocketListenerRearm.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 */, @@ -10509,6 +10550,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A28B087F0000000000000005 /* ViewerNavigationKeyRouter.swift in Sources */, ACE4FE1A8F1CC6C0B6A673AB /* VMClient.swift in Sources */, ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */, + A10020000000000000000011 /* WebSurfaceSelectionReader.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, 9065A0020000000000000001 /* WindowAppKitCapture.swift in Sources */, A5001900A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift in Sources */, @@ -10766,6 +10808,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C77070000000000000000008 /* CMUXCLI+SSHPTYAttachBridge.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 */, @@ -11452,6 +11495,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 842300000000000000000007 /* SurfaceResumeAgentBindingGenerationTests.swift in Sources */, F6572012A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift in Sources */, 842300000000000000000001 /* SurfaceResumeExitedAgentLivenessTests.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/CLIExplicitSurfaceRoutingTests.swift b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift index 7910734e3e1..22343495624 100644 --- a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift +++ b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift @@ -100,6 +100,89 @@ 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["url"] as? String == "https://example.test/document") + #expect(output["surface_ref"] as? String == "surface:1") + #expect(requests.compactMap { $0["method"] as? String } == ["surface.read_selection"]) + } + + @Test func readScreenSelectionAliasIsTextOnlyAndFailsWithoutASelection() 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) + #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 closeSurfaceRejectsMissingExplicitRefWithoutMutation() throws { let socketPath = Self.makeSocketPath("close") let listenerFD = try Self.bindUnixSocket(at: socketPath) @@ -393,6 +476,54 @@ struct CLIExplicitSurfaceRoutingTests { } } + 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: 5 + ) + + #expect(handled.wait(timeout: .now() + 5) == .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 @@ -414,6 +545,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/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift new file mode 100644 index 00000000000..8a31008f75a --- /dev/null +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -0,0 +1,240 @@ +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 nativeSelectionMapsUTF16RangesToOneBasedSourceLines() 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), + ("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 = 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(.timeLimit(.minutes(1))) + 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()) + defer { panel.close() } + let baseURL = try #require(URL(string: "https://selection.test/document")) + 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> + <input id="password" type="password" value="top-secret"> + </body></html> + """, + baseURL: baseURL, + in: panel.webView + ) + + _ = try await panel.evaluateJavaScript( + """ + (() => { + 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(); + selection.addRange(range); + return true; + })() + """ + ) + + 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) + + 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") + + _ = try await panel.evaluateJavaScript( + """ + (() => { + 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(); + selection.addRange(range); + childWindow.focus(); + return true; + })() + """ + ) + let frameSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(frameSnapshot.text == "selected frame words") + + _ = try await panel.evaluateJavaScript( + """ + (() => { + window.getSelection().removeAllRanges(); + const password = document.getElementById('password'); + password.focus(); + password.setSelectionRange(0, password.value.length); + return true; + })() + """ + ) + let passwordSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(!passwordSnapshot.hasSelection) + #expect(passwordSnapshot.text.isEmpty) + #expect(passwordSnapshot.url == baseURL.absoluteString) + } + + private func snapshot( + from result: SurfaceSelectionReadResult + ) throws -> SurfaceSelectionSnapshot { + guard case .snapshot(let snapshot) = result else { + throw SurfaceSelectionTestError.expectedSnapshot + } + return snapshot + } +} + +private enum SurfaceSelectionTestError: Error { + case expectedSnapshot +} + +@MainActor +private final class SurfaceSelectionNavigationLoader: NSObject, WKNavigationDelegate { + private var continuation: CheckedContinuation<Void, any Error>? + private weak var previousNavigationDelegate: (any WKNavigationDelegate)? + + func load( + _ html: String, + baseURL: URL, + in webView: WKWebView + ) async throws { + previousNavigationDelegate = webView.navigationDelegate + webView.navigationDelegate = self + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + webView.loadHTMLString(html, baseURL: baseURL) + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + finish(with: .success(()), webView: webView) + } + + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: any Error + ) { + finish(with: .failure(error), webView: webView) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error + ) { + finish(with: .failure(error), webView: webView) + } + + private func finish( + with result: Result<Void, any Error>, + webView: WKWebView + ) { + guard let continuation else { return } + self.continuation = nil + webView.navigationDelegate = previousNavigationDelegate + previousNavigationDelegate = nil + continuation.resume(with: result) + } +} diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 1fddd2fbb30..09121237e84 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -113,6 +113,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 { @@ -891,6 +913,54 @@ final class TerminalControllerSocketSecurityTests { XCTAssertEqual(error["message"] as? String, "Failed to read terminal selection") } + @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 testV1SetStatusIsServicedOnWorkerLaneWhileMainThreadIsBlocked() throws { let socketPath = makeSocketPath("v1-status-worker") let manager = TabManager() diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 6eb4c3b36c3..18386c16a6f 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -137,7 +137,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. | @@ -179,6 +180,69 @@ 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. +- `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. Plain output exits nonzero when there is no selection; +`cmux read-selection --json` preserves the successful +`has_selection: false` response for scripts. `cmux read-screen --selection` +uses the same socket path but prints only the text, 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":"surface:2"}' +``` + ## Command Families Auth subcommands: From 2f6ba709c346415b0e8585e4f3189553d007324f Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 11 Aug 2026 20:27:54 -0700 Subject: [PATCH 03/44] Harden surface selection contracts --- CLI/CMUXCLI+SurfaceSelection.swift | 17 +++-- Resources/Localizable.xcstrings | 25 ++++--- Sources/Panels/Panel.swift | 1 + .../Panels/SurfaceSelectionLineRange.swift | 11 ++- .../Panels/SurfaceSelectionReadResult.swift | 5 ++ Sources/Panels/SurfaceSelectionSnapshot.swift | 46 ++++++++++--- ...erminalController+ControlSurfaceDock.swift | 10 +++ .../TerminalController+SurfaceSelection.swift | 55 ++++++++++++--- .../Workspace+RemoteTmuxControlTopology.swift | 27 ++++++-- .../CLIExplicitSurfaceRoutingTests.swift | 31 ++++++++- cmuxTests/SurfaceSelectionTests.swift | 50 +++++++++++++- ...erminalControllerSocketSecurityTests.swift | 69 ++++++++++++++++++- docs/cli-contract.md | 8 +-- 13 files changed, 301 insertions(+), 54 deletions(-) diff --git a/CLI/CMUXCLI+SurfaceSelection.swift b/CLI/CMUXCLI+SurfaceSelection.swift index adbf2c9778e..35efdcb2295 100644 --- a/CLI/CMUXCLI+SurfaceSelection.swift +++ b/CLI/CMUXCLI+SurfaceSelection.swift @@ -72,13 +72,18 @@ extension CMUXCLI { return } guard (payload["has_selection"] as? Bool) == true else { - throw CLIError(message: String( - format: String( - localized: "cli.readSelection.error.noActiveSelection", - defaultValue: "%@: no active selection" - ), - commandName + 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) ?? "" diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b52e25fe3c5..7b66af3177d 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -15,11 +15,11 @@ "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.error.noActiveSelection": { + "cli.readSelection.output.noActiveSelection": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "%@: no active selection" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@: アクティブな選択範囲がありません" } } + "en": { "stringUnit": { "state": "translated", "value": "Has selection: false" } }, + "ja": { "stringUnit": { "state": "translated", "value": "選択範囲あり: false" } } } }, "cli.readSelection.error.readScreenConflict": { @@ -92,6 +92,13 @@ "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": { @@ -102,22 +109,22 @@ "socket.surfaceSelection.tabManagerUnavailable": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "TabManager is not available." } }, - "ja": { "stringUnit": { "state": "translated", "value": "TabManager を利用できません。" } } + "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": "Failed to read %@ selection" } }, - "ja": { "stringUnit": { "state": "translated", "value": "%@ の選択範囲を読み取れませんでした" } } + "en": { "stringUnit": { "state": "translated", "value": "Selection reading is currently unavailable." } }, + "ja": { "stringUnit": { "state": "translated", "value": "現在、選択範囲を読み取れません。" } } } }, "socket.surfaceSelection.unsupported": { "extractionState": "manual", "localizations": { - "en": { "stringUnit": { "state": "translated", "value": "Surface kind `%@` does not support selection reads." } }, - "ja": { "stringUnit": { "state": "translated", "value": "サーフェスの種類 `%@` は選択範囲の読み取りに対応していません。" } } + "en": { "stringUnit": { "state": "translated", "value": "This surface does not support selection reads." } }, + "ja": { "stringUnit": { "state": "translated", "value": "このサーフェスは選択範囲の読み取りに対応していません。" } } } }, "socket.surfaceSelection.workspaceNotFound": { diff --git a/Sources/Panels/Panel.swift b/Sources/Panels/Panel.swift index cb19653e64c..cafbbc79f48 100644 --- a/Sources/Panels/Panel.swift +++ b/Sources/Panels/Panel.swift @@ -362,6 +362,7 @@ 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 } diff --git a/Sources/Panels/SurfaceSelectionLineRange.swift b/Sources/Panels/SurfaceSelectionLineRange.swift index e8aab2372de..ccc3c4472e7 100644 --- a/Sources/Panels/SurfaceSelectionLineRange.swift +++ b/Sources/Panels/SurfaceSelectionLineRange.swift @@ -2,10 +2,19 @@ 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 - public init(start: Int, 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 index 034bcea63e0..5df1cf02d0e 100644 --- a/Sources/Panels/SurfaceSelectionReadResult.swift +++ b/Sources/Panels/SurfaceSelectionReadResult.swift @@ -2,7 +2,12 @@ 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 index 1789b48d4f7..5a3f57e93ce 100644 --- a/Sources/Panels/SurfaceSelectionSnapshot.swift +++ b/Sources/Panels/SurfaceSelectionSnapshot.swift @@ -2,29 +2,49 @@ import Foundation /// Immutable selection context shared by every selectable panel kind. public nonisolated struct SurfaceSelectionSnapshot: Equatable, Sendable { - public let hasSelection: Bool + 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 - public let text: String + + /// Selected text, or an empty string when there is no selection. + public var text: String { selectedText ?? "" } + + /// 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? - public init( - hasSelection: Bool, + private init( + selectedText: String?, kind: PanelType, - text: String, filePath: String?, lineRange: SurfaceSelectionLineRange?, url: String? ) { - self.hasSelection = hasSelection + self.selectedText = selectedText self.kind = kind - self.text = text 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, @@ -33,24 +53,28 @@ public nonisolated struct SurfaceSelectionSnapshot: Equatable, Sendable { url: String? = nil ) -> Self { Self( - hasSelection: true, + selectedText: text, kind: kind, - text: text, 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( - hasSelection: false, + selectedText: nil, kind: kind, - text: "", filePath: filePath, lineRange: nil, url: url 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+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift index 4339d094431..37c767d61bd 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -5,6 +5,11 @@ 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 { @@ -63,6 +68,30 @@ extension TerminalController { v2RefreshKnownRefs() let params = typedParams.mapValues(\.foundationObject) + let selectorKeys = [ + "window_id", + "group_id", + "workspace_id", + "surface_id", + "terminal_id", + "tab_id", + "pane_id", + ] + 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"), @@ -78,7 +107,17 @@ extension TerminalController { code: "unavailable", message: String( localized: "socket.surfaceSelection.tabManagerUnavailable", - defaultValue: "TabManager is not available." + defaultValue: "Selection reading is currently unavailable." + ) + ) + } + 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." ) ) } @@ -190,11 +229,8 @@ extension TerminalController { return failure( code: "not_supported", message: String( - format: String( - localized: "socket.surfaceSelection.unsupported", - defaultValue: "Surface kind `%@` does not support selection reads." - ), - panel.panelType.rawValue + localized: "socket.surfaceSelection.unsupported", + defaultValue: "This surface does not support selection reads." ), data: [ "surface_id": surfaceID.uuidString, @@ -205,11 +241,8 @@ extension TerminalController { return failure( code: "unavailable", message: String( - format: String( - localized: "socket.surfaceSelection.unavailable", - defaultValue: "Failed to read %@ selection" - ), - panel.panelType.rawValue + localized: "socket.surfaceSelection.unavailable", + defaultValue: "Selection reading is currently unavailable." ), data: [ "surface_id": surfaceID.uuidString, 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/cmuxTests/CLIExplicitSurfaceRoutingTests.swift b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift index 22343495624..a426e9ba211 100644 --- a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift +++ b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift @@ -139,12 +139,38 @@ struct CLIExplicitSurfaceRoutingTests { )) #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 readScreenSelectionAliasIsTextOnlyAndFailsWithoutASelection() throws { + @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"], @@ -164,7 +190,8 @@ struct CLIExplicitSurfaceRoutingTests { "base64": "", ] ) - #expect(emptyResult.status != 0) + #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"]) } diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 8a31008f75a..31bea0eacdc 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -12,6 +12,37 @@ import WebKit @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) + } + + @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() throws { let cases: [(source: String, selected: String, start: Int, end: Int)] = [ ("alpha\nbeta\ngamma", "beta", 2, 2), @@ -78,8 +109,7 @@ struct SurfaceSelectionTests { #expect(markdownSnapshot.lineRange == SurfaceSelectionLineRange(start: 2, end: 2)) } - @Test(.timeLimit(.minutes(1))) - func browserAndMarkdownPreviewReadLiveDOMSelection() async throws { + @Test func browserAndMarkdownPreviewReadLiveDOMSelection() async throws { let unloadedPanel = BrowserPanel(workspaceId: UUID()) defer { unloadedPanel.close() } let unloadedSnapshot = try snapshot(from: await unloadedPanel.readSurfaceSelection()) @@ -97,6 +127,7 @@ struct SurfaceSelectionTests { <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> """, @@ -158,6 +189,21 @@ struct SurfaceSelectionTests { let frameSnapshot = try snapshot(from: await panel.readSurfaceSelection()) #expect(frameSnapshot.text == "selected frame words") + _ = try await panel.evaluateJavaScript( + """ + (() => { + const editor = document.getElementById('editor'); + const start = editor.value.indexOf('selected editable words'); + editor.focus(); + editor.setSelectionRange(start, start + 'selected editable words'.length); + return true; + })() + """ + ) + let editableSnapshot = try snapshot(from: await panel.readSurfaceSelection()) + #expect(editableSnapshot.hasSelection) + #expect(editableSnapshot.text == "selected editable words") + _ = try await panel.evaluateJavaScript( """ (() => { diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 09121237e84..86db4c05dea 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -910,7 +910,7 @@ final class TerminalControllerSocketSecurityTests { 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, "Failed to read terminal selection") + XCTAssertEqual(error["message"] as? String, "Selection reading is currently unavailable.") } @Test func testSurfaceReadSelectionRoutesAnyPanelAndReturnsTheCommonShape() async throws { @@ -961,6 +961,73 @@ final class TerminalControllerSocketSecurityTests { 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 18386c16a6f..5c22c2e6d8c 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -228,10 +228,10 @@ 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. Plain output exits nonzero when there is no selection; -`cmux read-selection --json` preserves the successful -`has_selection: false` response for scripts. `cmux read-screen --selection` -uses the same socket path but prints only the text, and cannot be combined with +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: From 3caaad193b5d8e3daf3c16522f63973b3e97cccd Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 11 Aug 2026 20:28:00 -0700 Subject: [PATCH 04/44] Use modern SwiftUI change callback --- Sources/Sidebar/SidebarWorkspaceDescriptionText.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift b/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift index a1020e42561..870bc0dff2b 100644 --- a/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift +++ b/Sources/Sidebar/SidebarWorkspaceDescriptionText.swift @@ -62,7 +62,7 @@ struct SidebarWorkspaceDescriptionText: View { ) #endif } - .onChange(of: markdown) { newValue in + .onChange(of: markdown) { _, newValue in #if DEBUG let newlineCount = newValue.reduce(into: 0) { count, character in if character == "\n" { count += 1 } From 64428da19a7ee6b6adc428edb2d846ad1e5c420d Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 11 Aug 2026 20:48:58 -0700 Subject: [PATCH 05/44] Fix read-selection RPC example --- docs/cli-contract.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 5c22c2e6d8c..d3dcb89c0ea 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -240,7 +240,7 @@ Examples: 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":"surface:2"}' +cmux rpc surface.read_selection '{"surface_id":"83F4E6A4-5246-4DB8-A412-9CE7B059FA6C"}' ``` ## Command Families From 8c489655c20a3b7a30265ae5a9d135a5ceb10d2d Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 11 Aug 2026 23:18:23 -0700 Subject: [PATCH 06/44] Mount WebKit selection behavior fixture --- cmuxTests/SurfaceSelectionTests.swift | 40 ++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 31bea0eacdc..c59306c1897 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -118,7 +118,27 @@ struct SurfaceSelectionTests { #expect(unloadedSnapshot.url == nil) let panel = BrowserPanel(workspaceId: UUID()) - defer { panel.close() } + 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 = try #require(URL(string: "https://selection.test/document")) let loader = SurfaceSelectionNavigationLoader() try await loader.load( @@ -135,7 +155,7 @@ struct SurfaceSelectionTests { in: panel.webView ) - _ = try await panel.evaluateJavaScript( + let preparedBrowserSelection = try await panel.evaluateJavaScript( """ (() => { const node = document.getElementById('passage').firstChild; @@ -146,17 +166,20 @@ struct SurfaceSelectionTests { const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); - return true; + return selection.toString(); })() """ ) + #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, @@ -167,7 +190,7 @@ struct SurfaceSelectionTests { #expect(markdownSnapshot.text == "selected browser words") #expect(markdownSnapshot.filePath == "/tmp/guide.md") - _ = try await panel.evaluateJavaScript( + let preparedFrameSelection = try await panel.evaluateJavaScript( """ (() => { window.getSelection().removeAllRanges(); @@ -182,24 +205,27 @@ struct SurfaceSelectionTests { selection.removeAllRanges(); selection.addRange(range); childWindow.focus(); - return true; + return `${document.activeElement === frame}|${selection.toString()}`; })() """ ) + #expect(preparedFrameSelection as? String == "true|selected frame words") let frameSnapshot = try snapshot(from: await panel.readSurfaceSelection()) #expect(frameSnapshot.text == "selected frame words") - _ = try await panel.evaluateJavaScript( + let preparedEditableSelection = try await panel.evaluateJavaScript( """ (() => { const editor = document.getElementById('editor'); const start = editor.value.indexOf('selected editable words'); editor.focus(); editor.setSelectionRange(start, start + 'selected editable words'.length); - return true; + const selected = editor.value.slice(editor.selectionStart, editor.selectionEnd); + return `${document.activeElement === editor}|${selected}`; })() """ ) + #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") From 8f8778140ab4bc94abd693c7867d481248810acb Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 11 Aug 2026 23:46:20 -0700 Subject: [PATCH 07/44] Retain WebKit selections across focus handoff --- Sources/Panels/BrowserPanel.swift | 1 + Sources/Panels/MarkdownWebRenderer.swift | 1 + .../Panels/WebSurfaceSelectionReader.swift | 190 ++++++++++++++++-- cmuxTests/SurfaceSelectionTests.swift | 17 ++ 4 files changed, 190 insertions(+), 19 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index a551b8d9a6c..7c323716152 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2885,6 +2885,7 @@ final class BrowserPanel: Panel, ObservableObject { // Enable JavaScript configuration.defaultWebpagePreferences.allowsContentJavaScript = true + WebSurfaceSelectionReader.installTracking(in: configuration.userContentController) configuration.userContentController.addUserScript( WKUserScript( source: BrowserFileSystemAccessBridge.scriptSource, diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index 2a5da2aa6e6..2b23de489e5 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -50,6 +50,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. diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index cc2fdfb9b8a..652c95b26e6 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -13,56 +13,208 @@ nonisolated struct WebSurfaceSelectionReader { } } - private static let script = """ + private static let contentWorldName = "cmux.surface-selection" + + private static let trackingBootstrapScript = """ (() => { + if (globalThis.__cmuxSurfaceSelectionRuntime) return true; + const empty = () => ({ has_selection: false, text: '' }); - const readSelection = (targetWindow) => { + const unreadable = () => ({ has_selection: false, text: '', blocks_fallback: true }); + const selected = (text, sourceDocument = null) => ({ + has_selection: true, + text: String(text || ''), + source_document: sourceDocument + }); + const deepestActiveElement = (targetDocument) => { + let active = targetDocument.activeElement; + while (active?.shadowRoot?.activeElement) { + active = active.shadowRoot.activeElement; + } + return active; + }; + const readLiveSelection = (targetWindow) => { let targetDocument; try { targetDocument = targetWindow.document; } catch (_) { - return empty(); + return unreadable(); } - const active = targetDocument.activeElement; + const active = deepestActiveElement(targetDocument); const activeTag = String(active?.tagName || '').toLowerCase(); if (activeTag === 'iframe' || activeTag === 'frame') { try { const childWindow = active.contentWindow; - return childWindow ? readSelection(childWindow) : empty(); + return childWindow ? readLiveSelection(childWindow) : unreadable(); } catch (_) { - return empty(); + return unreadable(); } } const isInput = activeTag === 'input'; const isTextControl = isInput || activeTag === 'textarea'; const isPassword = isInput && String(active.type || '').toLowerCase() === 'password'; - if (isPassword) { - return empty(); - } + if (isPassword) return unreadable(); if (isTextControl && typeof active.selectionStart === 'number' && typeof active.selectionEnd === 'number' && active.selectionEnd > active.selectionStart) { - return { - has_selection: true, - text: String(active.value || '').slice(active.selectionStart, active.selectionEnd) - }; + return selected( + String(active.value || '').slice(active.selectionStart, active.selectionEnd), + targetDocument + ); } const selection = targetWindow.getSelection(); - const hasSelection = !!selection && selection.rangeCount > 0 && !selection.isCollapsed; - return { - has_selection: hasSelection, - text: hasSelection ? selection.toString() : '' + if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { + return selected(selection.toString(), targetDocument); + } + return empty(); + }; + + let retainedSelection = empty(); + let retainedDocument = null; + const clear = (sourceDocument = null) => { + if (sourceDocument && retainedDocument !== sourceDocument) return; + retainedSelection = empty(); + retainedDocument = null; + }; + const retain = (live) => { + retainedSelection = selected(live.text); + retainedDocument = live.source_document || null; + }; + const documentHasFocus = (targetDocument) => { + try { + return targetDocument.hasFocus(); + } catch (_) { + return false; + } + }; + const capture = (targetWindow) => { + const live = readLiveSelection(targetWindow); + if (live.blocks_fallback) { + clear(); + } else if (live.has_selection) { + retain(live); + } else { + let targetDocument = null; + try { + targetDocument = targetWindow.document; + } catch (_) {} + // WebKit can collapse a DOM selection when native focus leaves the + // web view. Keep the last snapshot through that handoff, but clear a + // collapse that occurred while the page still owned focus. + if (!targetDocument || documentHasFocus(targetDocument)) clear(); + } + }; + const read = () => { + const live = readLiveSelection(window); + if (live.blocks_fallback) { + clear(); + return empty(); + } + if (live.has_selection) { + retain(live); + return retainedSelection; + } + return retainedSelection; + }; + + const trackedDocuments = new WeakSet(); + const documentObservers = new WeakMap(); + let installDocument; + 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); + const captureDocument = () => { + const targetWindow = targetDocument.defaultView; + if (targetWindow) capture(targetWindow); }; + targetDocument.addEventListener('selectionchange', captureDocument, true); + targetDocument.addEventListener('select', captureDocument, true); + targetDocument.addEventListener('focusin', captureDocument, true); + targetDocument.addEventListener('input', captureDocument, 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 }); + scanFrames(targetDocument); + const observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of record.addedNodes || []) scanFrames(node); + } + }); + observer.observe(targetDocument, { childList: true, subtree: true }); + documentObservers.set(targetDocument, observer); + + const targetWindow = targetDocument.defaultView; + targetWindow?.addEventListener('beforeunload', () => { + if (targetDocument === document) { + clear(); + } else { + clear(targetDocument); + } + documentObservers.get(targetDocument)?.disconnect(); + documentObservers.delete(targetDocument); + }, true); }; - return JSON.stringify(readSelection(window)); + globalThis.__cmuxSurfaceSelectionRuntime = { read }; + installDocument(document); + capture(window); + return true; })() """ + private static let script = """ + (() => { + const runtime = globalThis.__cmuxSurfaceSelectionRuntime; + if (!runtime || typeof runtime.read !== 'function') return null; + const result = runtime.read(); + return JSON.stringify({ + has_selection: result?.has_selection === true, + text: result?.has_selection === true ? String(result.text || '') : '' + }); + })() + """ + + @MainActor + private static var contentWorld: WKContentWorld { + WKContentWorld.world(name: contentWorldName) + } + + @MainActor + static func installTracking(in userContentController: WKUserContentController) { + userContentController.addUserScript(WKUserScript( + source: trackingBootstrapScript, + injectionTime: .atDocumentStart, + forMainFrameOnly: true, + in: contentWorld + )) + } + @MainActor func read( webView: WKWebView, @@ -73,7 +225,7 @@ nonisolated struct WebSurfaceSelectionReader { do { guard let encoded = try await webView.evaluateJavaScript( Self.script, - contentWorld: .page + contentWorld: Self.contentWorld ) as? String, let data = encoded.data(using: .utf8) else { return .unavailable diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index c59306c1897..775b6978532 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -166,6 +166,7 @@ struct SurfaceSelectionTests { const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); + document.dispatchEvent(new Event('selectionchange')); return selection.toString(); })() """ @@ -190,6 +191,15 @@ struct SurfaceSelectionTests { #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)) + let preparedFrameSelection = try await panel.evaluateJavaScript( """ (() => { @@ -205,6 +215,7 @@ struct SurfaceSelectionTests { selection.removeAllRanges(); selection.addRange(range); childWindow.focus(); + childWindow.document.dispatchEvent(new Event('selectionchange')); return `${document.activeElement === frame}|${selection.toString()}`; })() """ @@ -220,6 +231,7 @@ struct SurfaceSelectionTests { const start = editor.value.indexOf('selected editable words'); editor.focus(); editor.setSelectionRange(start, start + 'selected editable words'.length); + editor.dispatchEvent(new Event('select', { bubbles: true })); const selected = editor.value.slice(editor.selectionStart, editor.selectionEnd); return `${document.activeElement === editor}|${selected}`; })() @@ -237,6 +249,7 @@ struct SurfaceSelectionTests { const password = document.getElementById('password'); password.focus(); password.setSelectionRange(0, password.value.length); + password.dispatchEvent(new Event('select', { bubbles: true })); return true; })() """ @@ -261,6 +274,10 @@ private enum SurfaceSelectionTestError: Error { case expectedSnapshot } +private final class SurfaceSelectionFirstResponderView: NSView { + override var acceptsFirstResponder: Bool { true } +} + @MainActor private final class SurfaceSelectionNavigationLoader: NSObject, WKNavigationDelegate { private var continuation: CheckedContinuation<Void, any Error>? From 47144ca131cd30188d747c64cbe1d88445b94550 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 01:02:25 -0700 Subject: [PATCH 08/44] Track WebKit selection in the page world --- Sources/Panels/WebSurfaceSelectionReader.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 652c95b26e6..09db3db6b4f 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -13,8 +13,6 @@ nonisolated struct WebSurfaceSelectionReader { } } - private static let contentWorldName = "cmux.surface-selection" - private static let trackingBootstrapScript = """ (() => { if (globalThis.__cmuxSurfaceSelectionRuntime) return true; @@ -200,18 +198,15 @@ nonisolated struct WebSurfaceSelectionReader { })() """ - @MainActor - private static var contentWorld: WKContentWorld { - WKContentWorld.world(name: contentWorldName) - } - + /// 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: contentWorld + in: .page )) } @@ -225,7 +220,7 @@ nonisolated struct WebSurfaceSelectionReader { do { guard let encoded = try await webView.evaluateJavaScript( Self.script, - contentWorld: Self.contentWorld + contentWorld: .page ) as? String, let data = encoded.data(using: .utf8) else { return .unavailable From 6389dd0fc47e538b9b662f36492c2149b2180dd8 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 01:24:10 -0700 Subject: [PATCH 09/44] Preserve WebKit selections across native focus --- .../Panels/WebSurfaceSelectionReader.swift | 68 +++++++++++-------- cmuxTests/SurfaceSelectionTests.swift | 17 +++++ 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 09db3db6b4f..d5b7d9873af 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -54,14 +54,16 @@ nonisolated struct WebSurfaceSelectionReader { const isTextControl = isInput || activeTag === 'textarea'; const isPassword = isInput && String(active.type || '').toLowerCase() === 'password'; if (isPassword) return unreadable(); - if (isTextControl && - typeof active.selectionStart === 'number' && - typeof active.selectionEnd === 'number' && - active.selectionEnd > active.selectionStart) { - return selected( - String(active.value || '').slice(active.selectionStart, active.selectionEnd), - targetDocument - ); + if (isTextControl) { + if (typeof active.selectionStart === 'number' && + typeof active.selectionEnd === 'number' && + active.selectionEnd > active.selectionStart) { + return selected( + String(active.value || '').slice(active.selectionStart, active.selectionEnd), + targetDocument + ); + } + return empty(); } const selection = targetWindow.getSelection(); @@ -82,28 +84,14 @@ nonisolated struct WebSurfaceSelectionReader { retainedSelection = selected(live.text); retainedDocument = live.source_document || null; }; - const documentHasFocus = (targetDocument) => { - try { - return targetDocument.hasFocus(); - } catch (_) { - return false; - } - }; - const capture = (targetWindow) => { + const capture = (targetWindow, clearWhenEmpty = false) => { const live = readLiveSelection(targetWindow); if (live.blocks_fallback) { clear(); } else if (live.has_selection) { retain(live); - } else { - let targetDocument = null; - try { - targetDocument = targetWindow.document; - } catch (_) {} - // WebKit can collapse a DOM selection when native focus leaves the - // web view. Keep the last snapshot through that handoff, but clear a - // collapse that occurred while the page still owned focus. - if (!targetDocument || documentHasFocus(targetDocument)) clear(); + } else if (clearWhenEmpty) { + clear(); } }; const read = () => { @@ -121,6 +109,16 @@ nonisolated struct WebSurfaceSelectionReader { const trackedDocuments = new WeakSet(); const documentObservers = new WeakMap(); + 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; + }; let installDocument; const installFrame = (frame) => { try { @@ -142,10 +140,24 @@ nonisolated struct WebSurfaceSelectionReader { const targetWindow = targetDocument.defaultView; if (targetWindow) capture(targetWindow); }; + const reconcileDocument = () => { + 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('focusin', captureDocument, true); - targetDocument.addEventListener('input', captureDocument, true); + targetDocument.addEventListener('select', reconcileDocument, true); + targetDocument.addEventListener('selectstart', clearForInteraction, true); + targetDocument.addEventListener('pointerdown', clearForInteraction, true); + targetDocument.addEventListener('keydown', (event) => { + if (keyChangesSelection(event)) clear(); + }, true); + targetDocument.addEventListener('focusin', reconcileDocument, true); + targetDocument.addEventListener('input', reconcileDocument, true); targetDocument.addEventListener('load', (event) => { const target = event?.target; const tag = String(target?.tagName || '').toLowerCase(); diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 775b6978532..6e50e4714ab 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -200,6 +200,23 @@ struct SurfaceSelectionTests { #expect(window.firstResponder === neighboringFirstResponder) #expect(window.makeFirstResponder(panel.webView)) + 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.evaluateJavaScript( """ (() => { From 8af3343419447baa51506f59e7cabc7df997b49d Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 01:41:50 -0700 Subject: [PATCH 10/44] Treat WebKit focus reconciliation as observational --- Sources/Panels/WebSurfaceSelectionReader.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index d5b7d9873af..a52829f1e8c 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -140,7 +140,7 @@ nonisolated struct WebSurfaceSelectionReader { const targetWindow = targetDocument.defaultView; if (targetWindow) capture(targetWindow); }; - const reconcileDocument = () => { + const reconcileInput = () => { const targetWindow = targetDocument.defaultView; if (targetWindow) capture(targetWindow, true); }; @@ -150,14 +150,15 @@ nonisolated struct WebSurfaceSelectionReader { // Concrete page interaction owns clearing; a later non-empty change // replaces the retained immutable snapshot. targetDocument.addEventListener('selectionchange', captureDocument, true); - targetDocument.addEventListener('select', reconcileDocument, 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', reconcileDocument, true); - targetDocument.addEventListener('input', reconcileDocument, true); + targetDocument.addEventListener('focusin', captureDocument, true); + targetDocument.addEventListener('input', reconcileInput, true); targetDocument.addEventListener('load', (event) => { const target = event?.target; const tag = String(target?.tagName || '').toLowerCase(); From d8d907f933d59784d187ab4dd6bcc3b1b5ebec88 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 01:58:45 -0700 Subject: [PATCH 11/44] Exercise WebKit selection callbacks in page world --- cmuxTests/SurfaceSelectionTests.swift | 65 ++++++++++++++++++--------- 1 file changed, 45 insertions(+), 20 deletions(-) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 6e50e4714ab..47951917afe 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -155,9 +155,9 @@ struct SurfaceSelectionTests { in: panel.webView ) - let preparedBrowserSelection = try await panel.evaluateJavaScript( + 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(); @@ -165,11 +165,17 @@ struct SurfaceSelectionTests { 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); - document.dispatchEvent(new Event('selectionchange')); - return selection.toString(); - })() - """ + }); + """, + arguments: [:], + in: nil, + contentWorld: .page ) #expect(preparedBrowserSelection as? String == "selected browser words") @@ -217,9 +223,9 @@ struct SurfaceSelectionTests { #expect(!clearedBrowserSnapshot.hasSelection) #expect(clearedBrowserSnapshot.text.isEmpty) - let preparedFrameSelection = try await panel.evaluateJavaScript( + 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; @@ -230,29 +236,48 @@ struct SurfaceSelectionTests { 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); - childWindow.focus(); - childWindow.document.dispatchEvent(new Event('selectionchange')); - return `${document.activeElement === frame}|${selection.toString()}`; - })() - """ + }); + """, + 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.evaluateJavaScript( + 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); - editor.dispatchEvent(new Event('select', { bubbles: true })); - const selected = editor.value.slice(editor.selectionStart, editor.selectionEnd); - return `${document.activeElement === editor}|${selected}`; - })() - """ + }); + """, + arguments: [:], + in: nil, + contentWorld: .page ) #expect(preparedEditableSelection as? String == "true|selected editable words") let editableSnapshot = try snapshot(from: await panel.readSurfaceSelection()) From e977f308a9177bb82c661143adda7116f2f1f262 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 02:14:29 -0700 Subject: [PATCH 12/44] Probe WebKit selection tracker state --- Sources/Panels/WebSurfaceSelectionReader.swift | 9 ++++++++- cmuxTests/SurfaceSelectionTests.swift | 13 +++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index a52829f1e8c..2324d775895 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -75,6 +75,7 @@ nonisolated struct WebSurfaceSelectionReader { let retainedSelection = empty(); let retainedDocument = null; + let lastObservedSelection = empty(); const clear = (sourceDocument = null) => { if (sourceDocument && retainedDocument !== sourceDocument) return; retainedSelection = empty(); @@ -89,6 +90,7 @@ nonisolated struct WebSurfaceSelectionReader { if (live.blocks_fallback) { clear(); } else if (live.has_selection) { + lastObservedSelection = selected(live.text); retain(live); } else if (clearWhenEmpty) { clear(); @@ -101,6 +103,7 @@ nonisolated struct WebSurfaceSelectionReader { return empty(); } if (live.has_selection) { + lastObservedSelection = selected(live.text); retain(live); return retainedSelection; } @@ -192,7 +195,11 @@ nonisolated struct WebSurfaceSelectionReader { }, true); }; - globalThis.__cmuxSurfaceSelectionRuntime = { read }; + const diagnostics = () => ({ + last_observed_text: lastObservedSelection.text, + retained_text: retainedSelection.text + }); + globalThis.__cmuxSurfaceSelectionRuntime = { read, diagnostics }; installDocument(document); capture(window); return true; diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 47951917afe..06febd6b0ac 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -178,6 +178,19 @@ struct SurfaceSelectionTests { contentWorld: .page ) #expect(preparedBrowserSelection as? String == "selected browser words") + let browserTrackerState = try await panel.webView.evaluateJavaScript( + """ + (() => { + const state = globalThis.__cmuxSurfaceSelectionRuntime?.diagnostics?.(); + return `${state?.last_observed_text || ''}|${state?.retained_text || ''}`; + })() + """, + contentWorld: .page + ) + #expect( + browserTrackerState as? String + == "selected browser words|selected browser words" + ) let browserFirstResponder = window.firstResponder let browserSnapshot = try snapshot(from: await panel.readSurfaceSelection()) From 79ee7df9663920167551f2a3a9a1eb7e9761ee97 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 02:30:58 -0700 Subject: [PATCH 13/44] Make WebKit selection reads snapshot-only --- .../Panels/WebSurfaceSelectionReader.swift | 25 ++++--------------- cmuxTests/SurfaceSelectionTests.swift | 13 ---------- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 2324d775895..6db7dd5a8cc 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -75,7 +75,6 @@ nonisolated struct WebSurfaceSelectionReader { let retainedSelection = empty(); let retainedDocument = null; - let lastObservedSelection = empty(); const clear = (sourceDocument = null) => { if (sourceDocument && retainedDocument !== sourceDocument) return; retainedSelection = empty(); @@ -90,25 +89,15 @@ nonisolated struct WebSurfaceSelectionReader { if (live.blocks_fallback) { clear(); } else if (live.has_selection) { - lastObservedSelection = selected(live.text); retain(live); } else if (clearWhenEmpty) { clear(); } }; - const read = () => { - const live = readLiveSelection(window); - if (live.blocks_fallback) { - clear(); - return empty(); - } - if (live.has_selection) { - lastObservedSelection = selected(live.text); - retain(live); - return retainedSelection; - } - return retainedSelection; - }; + // 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 read = () => retainedSelection; const trackedDocuments = new WeakSet(); const documentObservers = new WeakMap(); @@ -195,11 +184,7 @@ nonisolated struct WebSurfaceSelectionReader { }, true); }; - const diagnostics = () => ({ - last_observed_text: lastObservedSelection.text, - retained_text: retainedSelection.text - }); - globalThis.__cmuxSurfaceSelectionRuntime = { read, diagnostics }; + globalThis.__cmuxSurfaceSelectionRuntime = { read }; installDocument(document); capture(window); return true; diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 06febd6b0ac..47951917afe 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -178,19 +178,6 @@ struct SurfaceSelectionTests { contentWorld: .page ) #expect(preparedBrowserSelection as? String == "selected browser words") - let browserTrackerState = try await panel.webView.evaluateJavaScript( - """ - (() => { - const state = globalThis.__cmuxSurfaceSelectionRuntime?.diagnostics?.(); - return `${state?.last_observed_text || ''}|${state?.retained_text || ''}`; - })() - """, - contentWorld: .page - ) - #expect( - browserTrackerState as? String - == "selected browser words|selected browser words" - ) let browserFirstResponder = window.firstResponder let browserSnapshot = try snapshot(from: await panel.readSurfaceSelection()) From 4dbee454aa6279b83740efb0ec527465d4c56155 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 02:49:08 -0700 Subject: [PATCH 14/44] Preserve browser navigation lifecycle in selection test --- cmuxTests/SurfaceSelectionTests.swift | 60 +++++++++++++-------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 47951917afe..cc32c721656 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -314,6 +314,7 @@ struct SurfaceSelectionTests { private enum SurfaceSelectionTestError: Error { case expectedSnapshot + case navigationFailed(String) } private final class SurfaceSelectionFirstResponderView: NSView { @@ -321,51 +322,50 @@ private final class SurfaceSelectionFirstResponderView: NSView { } @MainActor -private final class SurfaceSelectionNavigationLoader: NSObject, WKNavigationDelegate { +private final class SurfaceSelectionNavigationLoader { private var continuation: CheckedContinuation<Void, any Error>? - private weak var previousNavigationDelegate: (any WKNavigationDelegate)? + 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 { - previousNavigationDelegate = webView.navigationDelegate - webView.navigationDelegate = self + 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) } } - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - finish(with: .success(()), webView: webView) - } - - func webView( - _ webView: WKWebView, - didFail navigation: WKNavigation!, - withError error: any Error - ) { - finish(with: .failure(error), webView: webView) - } - - func webView( - _ webView: WKWebView, - didFailProvisionalNavigation navigation: WKNavigation!, - withError error: any Error - ) { - finish(with: .failure(error), webView: webView) - } - - private func finish( - with result: Result<Void, any Error>, - webView: WKWebView - ) { + private func finish(with result: Result<Void, any Error>) { guard let continuation else { return } self.continuation = nil - webView.navigationDelegate = previousNavigationDelegate - previousNavigationDelegate = nil + navigationDelegate?.didFinish = previousDidFinish + navigationDelegate?.didFailNavigation = previousDidFailNavigation + navigationDelegate = nil + previousDidFinish = nil + previousDidFailNavigation = nil continuation.resume(with: result) } } From 5e76a327e7df4b0529be757054c1fde5adcf0e07 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 03:06:00 -0700 Subject: [PATCH 15/44] Use local URL for browser selection fixture --- cmuxTests/SurfaceSelectionTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index cc32c721656..b87b7b6a338 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -139,7 +139,7 @@ struct SurfaceSelectionTests { window.orderOut(nil) window.close() } - let baseURL = try #require(URL(string: "https://selection.test/document")) + let baseURL = URL(fileURLWithPath: "/tmp/cmux-selection-test/document.html") let loader = SurfaceSelectionNavigationLoader() try await loader.load( """ From 3cf798d358c55d7de397cf8f89205b06d30f6d07 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 12 Aug 2026 05:52:45 -0700 Subject: [PATCH 16/44] Stabilize browser portal layout assertions --- cmuxTests/BrowserPanelViewIdentityTests.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/cmuxTests/BrowserPanelViewIdentityTests.swift b/cmuxTests/BrowserPanelViewIdentityTests.swift index 61bbfd52277..9635ca28947 100644 --- a/cmuxTests/BrowserPanelViewIdentityTests.swift +++ b/cmuxTests/BrowserPanelViewIdentityTests.swift @@ -45,14 +45,16 @@ import WebKit let expectedFrameInWindow = anchor.convert(anchor.bounds, to: nil) NotificationCenter.default.post(name: NSWindow.didResizeNotification, object: window) - await waitForNextMainActorTurn() - await waitForNextMainActorTurn() - + // Assert before AppKit can service the intentionally dirty host on a later run-loop turn. #expect( referenceView.layoutPassCount == 0, "The browser portal must consume settled geometry without synchronously laying out SwiftUI's hosting view." ) #expect(referenceView.needsLayout) + + await waitForNextMainActorTurn() + await waitForNextMainActorTurn() + let snapshot = try #require( portal.debugSnapshot(forWebViewId: ObjectIdentifier(webView)) ) @@ -92,15 +94,17 @@ import WebKit webView.layoutPassCount = 0 portal.forceRefreshWebView(withId: ObjectIdentifier(webView), reason: "test") - await waitForNextMainActorTurn() - await waitForNextMainActorTurn() - - #expect(webView.layoutPassCount > 0, "The deferred refresh must still flush the portal-owned WebKit subtree.") + // Assert before AppKit can service the intentionally dirty host on a later run-loop turn. #expect( referenceView.layoutPassCount == 0, "A portal presentation refresh must not synchronously lay out SwiftUI's hosting view." ) #expect(referenceView.needsLayout) + + await waitForNextMainActorTurn() + await waitForNextMainActorTurn() + + #expect(webView.layoutPassCount > 0, "The deferred refresh must still flush the portal-owned WebKit subtree.") } @Test func portalAnchorInstallationDefersLayoutToAppKit() throws { From ecc564db5e89da36a6fe728e8beb694bee25aa12 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 15:20:03 -0700 Subject: [PATCH 17/44] Test stale browser selection invalidation --- cmuxTests/SurfaceSelectionTests.swift | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index b87b7b6a338..a13406302e3 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -300,6 +300,92 @@ struct SurfaceSelectionTests { #expect(!passwordSnapshot.hasSelection) #expect(passwordSnapshot.text.isEmpty) #expect(passwordSnapshot.url == baseURL.absoluteString) + + let preparedNavigationSelection = try await panel.webView.callAsyncJavaScript( + """ + return await new Promise((resolve) => { + 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) => { + 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 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) } private func snapshot( From 4880365241dc395a9546d3b1c6b6b6378a399b14 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 15:22:19 -0700 Subject: [PATCH 18/44] Invalidate stale selections and avoid topology sweeps --- .../Panels/WebSurfaceSelectionReader.swift | 48 ++++++++++++++++++- .../TerminalController+SurfaceSelection.swift | 15 +++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 6db7dd5a8cc..138f9f8fa8b 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -73,16 +73,27 @@ nonisolated struct WebSurfaceSelectionReader { 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 retainedLocation = null; const clear = (sourceDocument = null) => { if (sourceDocument && retainedDocument !== sourceDocument) return; retainedSelection = empty(); retainedDocument = null; + retainedLocation = null; }; const retain = (live) => { retainedSelection = selected(live.text); retainedDocument = live.source_document || null; + retainedLocation = locationForDocument(retainedDocument); }; const capture = (targetWindow, clearWhenEmpty = false) => { const live = readLiveSelection(targetWindow); @@ -97,7 +108,25 @@ nonisolated struct WebSurfaceSelectionReader { // 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 read = () => retainedSelection; + const read = () => { + if (retainedDocument && retainedLocation !== null) { + const currentLocation = locationForDocument(retainedDocument); + if (currentLocation !== null && currentLocation !== retainedLocation) { + clear(retainedDocument); + } + } + return retainedSelection; + }; + + const clearIfDetachedFrame = () => { + if (!retainedDocument || retainedDocument === document) return; + try { + const frame = retainedDocument.defaultView?.frameElement; + if (!frame || !frame.isConnected) clear(); + } catch (_) { + clear(); + } + }; const trackedDocuments = new WeakSet(); const documentObservers = new WeakMap(); @@ -151,6 +180,10 @@ nonisolated struct WebSurfaceSelectionReader { }, 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(); @@ -165,11 +198,22 @@ nonisolated struct WebSurfaceSelectionReader { }, { once: true }); scanFrames(targetDocument); const observer = new MutationObserver((records) => { + if (records.length > 0) { + if (retainedDocument === targetDocument) { + clear(targetDocument); + } else { + clearIfDetachedFrame(); + } + } for (const record of records) { for (const node of record.addedNodes || []) scanFrames(node); } }); - observer.observe(targetDocument, { childList: true, subtree: true }); + observer.observe(targetDocument, { + childList: true, + characterData: true, + subtree: true + }); documentObservers.set(targetDocument, observer); const targetWindow = targetDocument.defaultView; diff --git a/Sources/TerminalController+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift index 37c767d61bd..74ea73a9d16 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -66,7 +66,6 @@ extension TerminalController { )) } - v2RefreshKnownRefs() let params = typedParams.mapValues(\.foundationObject) let selectorKeys = [ "window_id", @@ -77,6 +76,20 @@ extension TerminalController { "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 hasOpaqueSelector = selectorKeys.contains { key in + guard v2HasNonNullParam(params, key), + let raw = params[key] as? String else { + return false + } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return !trimmed.isEmpty && UUID(uuidString: trimmed) == nil + } + if hasOpaqueSelector { + v2RefreshKnownRefs() + } if let invalidSelector = selectorKeys.first(where: { v2HasNonNullParam(params, $0) && v2UUID(params, $0) == nil }) { From 866806eb18531ec37c8b1f08e54634ded778cfd5 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 15:43:01 -0700 Subject: [PATCH 19/44] Keep current Ghostty and Bonsplit revisions --- ghostty | 2 +- vendor/bonsplit | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghostty b/ghostty index f76c132e526..5045df3f207 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit f76c132e526f124fe4aaebd39f516751656844bc +Subproject commit 5045df3f2072f0725394c87cebcc854b634e2131 diff --git a/vendor/bonsplit b/vendor/bonsplit index 5b61b184336..18f3f8bc2ac 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 5b61b184336756deb0c435e1f79dd1c839eb0281 +Subproject commit 18f3f8bc2ac3019d0f30b41586f4785950af6de7 From cf10d6bf06d5b61a886f43727cbdc95291519b6a Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 15:44:34 -0700 Subject: [PATCH 20/44] Bound selection invalidation and polling --- ...trolCommandExecutionPolicy+ReadPlane.swift | 1 + .../ControlPlaneConcurrencyTests.swift | 3 ++ .../Panels/WebSurfaceSelectionReader.swift | 48 ++++++++++++++----- 3 files changed, 40 insertions(+), 12 deletions(-) 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/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/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 138f9f8fa8b..c70ad190429 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -81,19 +81,27 @@ nonisolated struct WebSurfaceSelectionReader { return null; } }; + const selectionObservers = new WeakMap(); + let armSelectionObserver = (_) => {}; + let disarmSelectionObserver = (_) => {}; let retainedSelection = empty(); let retainedDocument = null; let retainedLocation = null; const clear = (sourceDocument = null) => { if (sourceDocument && retainedDocument !== sourceDocument) return; + if (retainedDocument) disarmSelectionObserver(retainedDocument); retainedSelection = empty(); retainedDocument = null; retainedLocation = null; }; const retain = (live) => { + if (retainedDocument && retainedDocument !== live.source_document) { + disarmSelectionObserver(retainedDocument); + } retainedSelection = selected(live.text); retainedDocument = live.source_document || null; retainedLocation = locationForDocument(retainedDocument); + if (retainedDocument) armSelectionObserver(retainedDocument); }; const capture = (targetWindow, clearWhenEmpty = false) => { const live = readLiveSelection(targetWindow); @@ -154,6 +162,28 @@ nonisolated struct WebSurfaceSelectionReader { for (const frame of frames) installFrame(frame); } catch (_) {} }; + armSelectionObserver = (targetDocument) => { + if (!targetDocument || selectionObservers.has(targetDocument)) return; + const observer = new MutationObserver(() => { + observer.disconnect(); + selectionObservers.delete(targetDocument); + if (retainedDocument === targetDocument) { + clear(targetDocument); + } else { + clearIfDetachedFrame(); + } + }); + observer.observe(targetDocument, { + childList: true, + characterData: true, + subtree: true + }); + selectionObservers.set(targetDocument, observer); + }; + disarmSelectionObserver = (targetDocument) => { + selectionObservers.get(targetDocument)?.disconnect(); + selectionObservers.delete(targetDocument); + }; installDocument = (targetDocument) => { if (!targetDocument || trackedDocuments.has(targetDocument)) return; trackedDocuments.add(targetDocument); @@ -197,23 +227,16 @@ nonisolated struct WebSurfaceSelectionReader { captureDocument(); }, { once: true }); scanFrames(targetDocument); + // This observer only discovers newly inserted frames. Content + // invalidation is armed lazily by `retain`, so ordinary pages do not + // pay for a whole-document character-data observer between reads. const observer = new MutationObserver((records) => { - if (records.length > 0) { - if (retainedDocument === targetDocument) { - clear(targetDocument); - } else { - clearIfDetachedFrame(); - } - } + if (records.length > 0) clearIfDetachedFrame(); for (const record of records) { for (const node of record.addedNodes || []) scanFrames(node); } }); - observer.observe(targetDocument, { - childList: true, - characterData: true, - subtree: true - }); + observer.observe(targetDocument, { childList: true, subtree: true }); documentObservers.set(targetDocument, observer); const targetWindow = targetDocument.defaultView; @@ -223,6 +246,7 @@ nonisolated struct WebSurfaceSelectionReader { } else { clear(targetDocument); } + disarmSelectionObserver(targetDocument); documentObservers.get(targetDocument)?.disconnect(); documentObservers.delete(targetDocument); }, true); From 927ed85d7f74f12a8d0b9d49aaf3e69ba64b41bf Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 15:59:26 -0700 Subject: [PATCH 21/44] Harden async and relayed selection reads --- Sources/Panels/FilePreviewPanel.swift | 2 +- Sources/Panels/MarkdownPanel.swift | 2 +- .../NativeTextSurfaceSelectionReader.swift | 40 ++++++-- .../Panels/WebSurfaceSelectionReader.swift | 96 +++++++++++-------- ...erminalController+ControlSocketAsync.swift | 55 +++++++++++ ...lController+RemoteRelayAuthorization.swift | 2 + .../TerminalController+SurfaceSelection.swift | 9 +- cmuxTests/RemoteResumeBindingTests.swift | 32 +++++++ cmuxTests/SurfaceSelectionTests.swift | 26 ++++- 9 files changed, 212 insertions(+), 52 deletions(-) diff --git a/Sources/Panels/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index cdbd25fecf7..22360c18a05 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1076,7 +1076,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan func readSurfaceSelection() async -> SurfaceSelectionReadResult { guard previewMode == .text else { return .unsupported } - return .snapshot(NativeTextSurfaceSelectionReader().read( + return .snapshot(await NativeTextSurfaceSelectionReader().read( textView: textView, kind: .filePreview, filePath: filePath diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index e4c5c398f49..5409b74efab 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -271,7 +271,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel func readSurfaceSelection() async -> SurfaceSelectionReadResult { switch displayMode { case .text: - return .snapshot(NativeTextSurfaceSelectionReader().read( + return .snapshot(await NativeTextSurfaceSelectionReader().read( textView: textView, kind: .markdown, filePath: filePath diff --git a/Sources/Panels/NativeTextSurfaceSelectionReader.swift b/Sources/Panels/NativeTextSurfaceSelectionReader.swift index 0a140636272..5b27db5b5ac 100644 --- a/Sources/Panels/NativeTextSurfaceSelectionReader.swift +++ b/Sources/Panels/NativeTextSurfaceSelectionReader.swift @@ -8,7 +8,7 @@ nonisolated struct NativeTextSurfaceSelectionReader { textView: NSTextView?, kind: PanelType, filePath: String - ) -> SurfaceSelectionSnapshot { + ) async -> SurfaceSelectionSnapshot { let normalizedPath = URL(fileURLWithPath: filePath).standardizedFileURL.path guard let textView else { return .none(kind: kind, filePath: normalizedPath) @@ -23,18 +23,44 @@ nonisolated struct NativeTextSurfaceSelectionReader { return .none(kind: kind, filePath: normalizedPath) } - let startLine = lineNumber(atUTF16Offset: selectedRange.location, in: source) - let lastSelectedOffset = NSMaxRange(selectedRange) - 1 - let endLine = lineNumber(atUTF16Offset: lastSelectedOffset, in: source) + return await Self.makeSnapshot( + source: source as String, + selectedLocation: selectedRange.location, + selectedLength: selectedRange.length, + selectedText: source.substring(with: selectedRange), + kind: kind, + filePath: normalizedPath + ) + } + + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + private nonisolated static func makeSnapshot( + source: String, + selectedLocation: Int, + selectedLength: Int, + selectedText: String, + kind: PanelType, + filePath: String + ) async -> SurfaceSelectionSnapshot { + let source = source as NSString + let startLine = lineNumber(atUTF16Offset: selectedLocation, in: source) + let endLine = lineNumber( + atUTF16Offset: selectedLocation + selectedLength - 1, + in: source + ) return .selected( kind: kind, - text: source.substring(with: selectedRange), - filePath: normalizedPath, + text: selectedText, + filePath: filePath, lineRange: SurfaceSelectionLineRange(start: startLine, end: endLine) ) } - private func lineNumber(atUTF16Offset offset: Int, in source: NSString) -> Int { + private nonisolated static func lineNumber(atUTF16Offset offset: Int, in source: NSString) -> Int { guard offset > 0 else { return 1 } var lineNumber = 1 diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index c70ad190429..38d15f31f8e 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -19,10 +19,11 @@ nonisolated struct WebSurfaceSelectionReader { const empty = () => ({ has_selection: false, text: '' }); const unreadable = () => ({ has_selection: false, text: '', blocks_fallback: true }); - const selected = (text, sourceDocument = null) => ({ + const selected = (text, sourceDocument = null, metadata = {}) => ({ has_selection: true, text: String(text || ''), - source_document: sourceDocument + source_document: sourceDocument, + ...metadata }); const deepestActiveElement = (targetDocument) => { let active = targetDocument.activeElement; @@ -60,7 +61,12 @@ nonisolated struct WebSurfaceSelectionReader { active.selectionEnd > active.selectionStart) { return selected( String(active.value || '').slice(active.selectionStart, active.selectionEnd), - targetDocument + targetDocument, + { + selection_control: active, + selection_start: active.selectionStart, + selection_end: active.selectionEnd + } ); } return empty(); @@ -68,7 +74,13 @@ nonisolated struct WebSurfaceSelectionReader { const selection = targetWindow.getSelection(); if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { - return selected(selection.toString(), targetDocument); + try { + return selected(selection.toString(), targetDocument, { + selection_range: selection.getRangeAt(0).cloneRange() + }); + } catch (_) { + return unreadable(); + } } return empty(); }; @@ -81,27 +93,31 @@ nonisolated struct WebSurfaceSelectionReader { return null; } }; - const selectionObservers = new WeakMap(); - let armSelectionObserver = (_) => {}; - let disarmSelectionObserver = (_) => {}; let retainedSelection = empty(); let retainedDocument = null; let retainedLocation = null; + let retainedRange = null; + let retainedControl = null; + let retainedControlStart = null; + let retainedControlEnd = null; const clear = (sourceDocument = null) => { if (sourceDocument && retainedDocument !== sourceDocument) return; - if (retainedDocument) disarmSelectionObserver(retainedDocument); retainedSelection = empty(); retainedDocument = null; retainedLocation = null; + retainedRange = null; + retainedControl = null; + retainedControlStart = null; + retainedControlEnd = null; }; const retain = (live) => { - if (retainedDocument && retainedDocument !== live.source_document) { - disarmSelectionObserver(retainedDocument); - } retainedSelection = selected(live.text); retainedDocument = live.source_document || null; retainedLocation = locationForDocument(retainedDocument); - if (retainedDocument) armSelectionObserver(retainedDocument); + retainedRange = live.selection_range || null; + retainedControl = live.selection_control || null; + retainedControlStart = live.selection_start ?? null; + retainedControlEnd = live.selection_end ?? null; }; const capture = (targetWindow, clearWhenEmpty = false) => { const live = readLiveSelection(targetWindow); @@ -116,7 +132,34 @@ nonisolated struct WebSurfaceSelectionReader { // 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; + } + return retainedRange.toString() === retainedSelection.text; + } catch (_) { + return false; + } + } + if (retainedControl) { + try { + if (retainedControl.isConnected === false) return false; + const start = Number(retainedControlStart); + const end = Number(retainedControlEnd); + return String(retainedControl.value || '').slice(start, end) === retainedSelection.text; + } catch (_) { + return false; + } + } + return true; + }; const read = () => { + if (retainedDocument && !retainedContentStillValid()) { + clear(retainedDocument); + } if (retainedDocument && retainedLocation !== null) { const currentLocation = locationForDocument(retainedDocument); if (currentLocation !== null && currentLocation !== retainedLocation) { @@ -162,28 +205,6 @@ nonisolated struct WebSurfaceSelectionReader { for (const frame of frames) installFrame(frame); } catch (_) {} }; - armSelectionObserver = (targetDocument) => { - if (!targetDocument || selectionObservers.has(targetDocument)) return; - const observer = new MutationObserver(() => { - observer.disconnect(); - selectionObservers.delete(targetDocument); - if (retainedDocument === targetDocument) { - clear(targetDocument); - } else { - clearIfDetachedFrame(); - } - }); - observer.observe(targetDocument, { - childList: true, - characterData: true, - subtree: true - }); - selectionObservers.set(targetDocument, observer); - }; - disarmSelectionObserver = (targetDocument) => { - selectionObservers.get(targetDocument)?.disconnect(); - selectionObservers.delete(targetDocument); - }; installDocument = (targetDocument) => { if (!targetDocument || trackedDocuments.has(targetDocument)) return; trackedDocuments.add(targetDocument); @@ -227,9 +248,9 @@ nonisolated struct WebSurfaceSelectionReader { captureDocument(); }, { once: true }); scanFrames(targetDocument); - // This observer only discovers newly inserted frames. Content - // invalidation is armed lazily by `retain`, so ordinary pages do not - // pay for a whole-document character-data observer between reads. + // This observer only discovers newly inserted frames and detects a + // detached selected iframe. Selection content is revalidated lazily + // by `read`, so unrelated page mutations stay observable. const observer = new MutationObserver((records) => { if (records.length > 0) clearIfDetachedFrame(); for (const record of records) { @@ -246,7 +267,6 @@ nonisolated struct WebSurfaceSelectionReader { } else { clear(targetDocument); } - disarmSelectionObserver(targetDocument); documentObservers.get(targetDocument)?.disconnect(); documentObservers.delete(targetDocument); }, true); diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index b20f70bf102..c16d1e193a5 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 ControlCommandExecutionPolicy.servesFromPublishedReadSnapshot(method: request.method), let snapshotResult = socketReadSnapshotStore.response( method: request.method, @@ -185,6 +189,57 @@ extension TerminalController { return socketWorkerV2Response(handling: request) } + private enum SurfaceSelectionAsyncError: Error { + case timeout + } + + /// 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 { + do { + return try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + let result = await self.v2SurfaceReadSelection(params: request.params) + return self.v2Result(id: request.id?.foundationObject, result) + } + group.addTask { + try await Task.sleep(for: .seconds(5)) + throw SurfaceSelectionAsyncError.timeout + } + defer { group.cancelAll() } + guard let response = try await group.next() else { + return self.v2Error( + id: request.id?.foundationObject, + code: "request_error", + message: "Request failed before returning a result" + ) + } + return response + } + } catch is SurfaceSelectionAsyncError { + return v2Error( + id: request.id?.foundationObject, + code: "timeout", + message: "Request timed out after 5 seconds" + ) + } catch is CancellationError { + return v2Error( + id: request.id?.foundationObject, + code: "timeout", + message: "Request timed out after 5 seconds" + ) + } catch { + return 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+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 index 74ea73a9d16..e29d367a593 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -79,15 +79,18 @@ extension TerminalController { // 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 hasOpaqueSelector = selectorKeys.contains { key in + 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) - return !trimmed.isEmpty && UUID(uuidString: trimmed) == nil + guard !trimmed.isEmpty, UUID(uuidString: trimmed) == nil else { + return false + } + return v2MainSync { v2ResolveHandleRef(trimmed) } == nil } - if hasOpaqueSelector { + if hasUnresolvedOpaqueSelector { v2RefreshKnownRefs() } if let invalidSelector = selectorKeys.first(where: { 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/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index a13406302e3..2acedb7fea4 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -43,7 +43,7 @@ struct SurfaceSelectionTests { ) == nil) } - @Test func nativeSelectionMapsUTF16RangesToOneBasedSourceLines() throws { + @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), @@ -56,7 +56,7 @@ struct SurfaceSelectionTests { let selectedRange = (testCase.source as NSString).range(of: testCase.selected) textView.setSelectedRange(selectedRange) - let snapshot = NativeTextSurfaceSelectionReader().read( + let snapshot = await NativeTextSurfaceSelectionReader().read( textView: textView, kind: .filePreview, filePath: "/tmp/../tmp/example.swift" @@ -366,6 +366,28 @@ struct SurfaceSelectionTests { ) #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) => { From 255cb2063acdf2ff5b36329daf6b3533aa48dd45 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 16:13:00 -0700 Subject: [PATCH 22/44] Make selection timeout and RPC routing safe --- CLI/cmux.swift | 5 ++ ...erminalController+ControlSocketAsync.swift | 79 ++++++++++--------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 7fd98965817..25291342133 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3687,6 +3687,11 @@ struct CMUXCLI { 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 } diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index c16d1e193a5..5962ead40d2 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -189,55 +189,58 @@ extension TerminalController { return socketWorkerV2Response(handling: request) } - private enum SurfaceSelectionAsyncError: Error { - case timeout - } - /// 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 { - do { - return try await withThrowingTaskGroup(of: String.self) { group in - group.addTask { - let result = await self.v2SurfaceReadSelection(params: request.params) - return self.v2Result(id: request.id?.foundationObject, result) - } - group.addTask { - try await Task.sleep(for: .seconds(5)) - throw SurfaceSelectionAsyncError.timeout - } - defer { group.cancelAll() } - guard let response = try await group.next() else { - return self.v2Error( - id: request.id?.foundationObject, - code: "request_error", - message: "Request failed before returning a result" - ) - } - return response + 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 timeout = Task { + do { + try await Task.sleep(for: .seconds(5)) + } catch { + return } - } catch is SurfaceSelectionAsyncError { - return v2Error( - id: request.id?.foundationObject, - code: "timeout", - message: "Request timed out after 5 seconds" - ) - } catch is CancellationError { - return v2Error( + guard !Task.isCancelled else { return } + continuation.yield(self.v2Error( id: request.id?.foundationObject, code: "timeout", message: "Request timed out after 5 seconds" - ) - } catch { - return v2Error( - id: request.id?.foundationObject, - code: "request_error", - message: "Request failed before returning a result" - ) + )) + 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 { From def4539039ba54b7a1076ce3c838f7cbef9d17f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 16:29:27 -0700 Subject: [PATCH 23/44] Bound terminal and raw RPC selection reads --- CLI/cmux.swift | 9 ++++++++- Sources/Panels/MarkdownWebRenderer.swift | 5 +++-- Sources/Panels/TerminalPanel.swift | 11 ++++++++++- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 25291342133..1aee7b0d4c7 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5187,7 +5187,14 @@ struct CMUXCLI { !method.isEmpty else { throw CLIError(message: "Usage: cmux rpc <method> [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)) diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index 8abe04547a6..bc8c98e6f44 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -347,13 +347,14 @@ struct MarkdownWebRenderer: NSViewRepresentable { } func readSurfaceSelection(filePath: String) async -> SurfaceSelectionReadResult { + let normalizedPath = URL(fileURLWithPath: filePath).standardizedFileURL.path guard isLoaded, let webView else { - return .snapshot(.none(kind: .markdown, filePath: filePath)) + return .snapshot(.none(kind: .markdown, filePath: normalizedPath)) } return await WebSurfaceSelectionReader().read( webView: webView, kind: .markdown, - filePath: filePath + filePath: normalizedPath ) } diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index ff3a2601b46..0da74aad04b 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -111,6 +111,11 @@ final class TerminalPanel: Panel, ObservableObject { "terminal.fill" } + /// Caps one socket-visible selection read before Ghostty formats or + /// allocates the returned text. Oversized selections are reported as empty + /// rather than allowing a polling client to force an unbounded copy. + private static let maximumSelectionBytes = 1_048_576 + func readSurfaceSelection() async -> SurfaceSelectionReadResult { guard let liveSurface = surface.liveSurfaceForGhosttyAccess( reason: "readSurfaceSelection" @@ -119,7 +124,11 @@ final class TerminalPanel: Panel, ObservableObject { } var selection = ghostty_text_s() - guard ghostty_surface_read_selection(liveSurface, &selection) else { + guard ghostty_surface_read_selection_clipboard_text( + liveSurface, + UInt(Self.maximumSelectionBytes), + &selection + ) else { return .snapshot(.none(kind: .terminal)) } defer { ghostty_surface_free_text(liveSurface, &selection) } From a022bcd8adc4e0e3a6dffada5cd0a9faf33730f0 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 16:46:41 -0700 Subject: [PATCH 24/44] Bound shared selection snapshots --- .../NativeTextSurfaceSelectionReader.swift | 11 ++++++++--- Sources/Panels/SurfaceSelectionSnapshot.swift | 12 ++++++++++++ Sources/Panels/TerminalPanel.swift | 7 +------ Sources/Panels/WebSurfaceSelectionReader.swift | 16 ++++++++++++++-- .../TerminalController+SurfaceSelection.swift | 1 + cmuxTests/SurfaceSelectionTests.swift | 6 ++++++ docs/cli-contract.md | 2 ++ 7 files changed, 44 insertions(+), 11 deletions(-) diff --git a/Sources/Panels/NativeTextSurfaceSelectionReader.swift b/Sources/Panels/NativeTextSurfaceSelectionReader.swift index 5b27db5b5ac..04b45bf6922 100644 --- a/Sources/Panels/NativeTextSurfaceSelectionReader.swift +++ b/Sources/Panels/NativeTextSurfaceSelectionReader.swift @@ -27,7 +27,6 @@ nonisolated struct NativeTextSurfaceSelectionReader { source: source as String, selectedLocation: selectedRange.location, selectedLength: selectedRange.length, - selectedText: source.substring(with: selectedRange), kind: kind, filePath: normalizedPath ) @@ -42,7 +41,6 @@ nonisolated struct NativeTextSurfaceSelectionReader { source: String, selectedLocation: Int, selectedLength: Int, - selectedText: String, kind: PanelType, filePath: String ) async -> SurfaceSelectionSnapshot { @@ -52,9 +50,16 @@ nonisolated struct NativeTextSurfaceSelectionReader { atUTF16Offset: selectedLocation + selectedLength - 1, in: source ) + let boundedLength = min( + selectedLength, + SurfaceSelectionSnapshot.maximumTextBytes + ) + let boundedSelection = source.substring( + with: NSRange(location: selectedLocation, length: boundedLength) + ) return .selected( kind: kind, - text: selectedText, + text: SurfaceSelectionSnapshot.boundedText(boundedSelection), filePath: filePath, lineRange: SurfaceSelectionLineRange(start: startLine, end: endLine) ) diff --git a/Sources/Panels/SurfaceSelectionSnapshot.swift b/Sources/Panels/SurfaceSelectionSnapshot.swift index 5a3f57e93ce..69bdb24948d 100644 --- a/Sources/Panels/SurfaceSelectionSnapshot.swift +++ b/Sources/Panels/SurfaceSelectionSnapshot.swift @@ -2,6 +2,9 @@ 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. @@ -13,6 +16,15 @@ public nonisolated struct SurfaceSelectionSnapshot: Equatable, Sendable { /// 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) + return String(decoding: text.utf8.prefix(budget), as: UTF8.self) + marker + } + /// Standardized source path when the selection belongs to a document. public let filePath: String? diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index 0da74aad04b..f2dea2d46a6 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -111,11 +111,6 @@ final class TerminalPanel: Panel, ObservableObject { "terminal.fill" } - /// Caps one socket-visible selection read before Ghostty formats or - /// allocates the returned text. Oversized selections are reported as empty - /// rather than allowing a polling client to force an unbounded copy. - private static let maximumSelectionBytes = 1_048_576 - func readSurfaceSelection() async -> SurfaceSelectionReadResult { guard let liveSurface = surface.liveSurfaceForGhosttyAccess( reason: "readSurfaceSelection" @@ -126,7 +121,7 @@ final class TerminalPanel: Panel, ObservableObject { var selection = ghostty_text_s() guard ghostty_surface_read_selection_clipboard_text( liveSurface, - UInt(Self.maximumSelectionBytes), + UInt(SurfaceSelectionSnapshot.maximumTextBytes), &selection ) else { return .snapshot(.none(kind: .terminal)) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 38d15f31f8e..9cf5ab576be 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -17,6 +17,12 @@ nonisolated struct WebSurfaceSelectionReader { (() => { if (globalThis.__cmuxSurfaceSelectionRuntime) return true; + const maxTextCharacters = \(SurfaceSelectionSnapshot.maximumTextBytes / 4); + const boundedText = (text) => { + const value = String(text || ''); + if (value.length <= maxTextCharacters) return value; + return value.slice(0, maxTextCharacters - 1) + '…'; + }; const empty = () => ({ has_selection: false, text: '' }); const unreadable = () => ({ has_selection: false, text: '', blocks_fallback: true }); const selected = (text, sourceDocument = null, metadata = {}) => ({ @@ -283,10 +289,16 @@ nonisolated struct WebSurfaceSelectionReader { (() => { const runtime = globalThis.__cmuxSurfaceSelectionRuntime; if (!runtime || typeof runtime.read !== 'function') return null; + const maxTextCharacters = \(SurfaceSelectionSnapshot.maximumTextBytes / 4); + const boundedText = (text) => { + const value = String(text || ''); + if (value.length <= maxTextCharacters) return value; + return value.slice(0, maxTextCharacters - 1) + '…'; + }; const result = runtime.read(); return JSON.stringify({ has_selection: result?.has_selection === true, - text: result?.has_selection === true ? String(result.text || '') : '' + text: result?.has_selection === true ? boundedText(result.text) : '' }); })() """ @@ -325,7 +337,7 @@ nonisolated struct WebSurfaceSelectionReader { if payload.hasSelection { return .snapshot(.selected( kind: kind, - text: payload.text, + text: SurfaceSelectionSnapshot.boundedText(payload.text), filePath: normalizedPath, url: url )) diff --git a/Sources/TerminalController+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift index e29d367a593..ee3721410a3 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -51,6 +51,7 @@ extension TerminalController { } } + @MainActor private func captureSurfaceSelection( params typedParams: [String: JSONValue] ) async -> SurfaceSelectionSocketCaptureOutcome { diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 2acedb7fea4..0fcae5cb3f8 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -24,6 +24,12 @@ struct SurfaceSelectionTests { #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("…")) } @Test func paneRoutingSelectsItsSurfaceAndFailsClosed() throws { diff --git a/docs/cli-contract.md b/docs/cli-contract.md index a7192e45942..5b4525b0bf6 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -209,6 +209,8 @@ Successful responses use one shape across surface kinds: ``` - `has_selection`, `kind`, `text`, and `base64` are always present. +- Selection text is capped at 1 MiB; an oversized selection is shortened with a + visible ellipsis before it crosses the socket boundary. - `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 From 802370a22ccd9637715523dd7a14696f98fb4a60 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 16:57:15 -0700 Subject: [PATCH 25/44] Test immutable WebKit selection bridge --- cmuxTests/SurfaceSelectionTests.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 0fcae5cb3f8..e593ea34240 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -302,6 +302,18 @@ struct SurfaceSelectionTests { })() """ ) + 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) From 024b7cf4045bf1b5d1536c0f61c0c11abbb8c72d Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 16:58:27 -0700 Subject: [PATCH 26/44] Harden WebKit selection lifecycle and deadlines --- .../Panels/WebSurfaceSelectionReader.swift | 56 +++++++++---------- ...erminalController+ControlSocketAsync.swift | 4 +- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 9cf5ab576be..ae094906b64 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -27,7 +27,7 @@ nonisolated struct WebSurfaceSelectionReader { const unreadable = () => ({ has_selection: false, text: '', blocks_fallback: true }); const selected = (text, sourceDocument = null, metadata = {}) => ({ has_selection: true, - text: String(text || ''), + text: boundedText(text), source_document: sourceDocument, ...metadata }); @@ -145,7 +145,7 @@ nonisolated struct WebSurfaceSelectionReader { retainedRange.endContainer?.isConnected === false) { return false; } - return retainedRange.toString() === retainedSelection.text; + return boundedText(retainedRange.toString()) === retainedSelection.text; } catch (_) { return false; } @@ -155,14 +155,25 @@ nonisolated struct WebSurfaceSelectionReader { if (retainedControl.isConnected === false) return false; const start = Number(retainedControlStart); const end = Number(retainedControlEnd); - return String(retainedControl.value || '').slice(start, end) === retainedSelection.text; + return boundedText(String(retainedControl.value || '').slice(start, end)) === retainedSelection.text; } catch (_) { return false; } } return true; }; + const clearIfDetachedFrame = () => { + 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); } @@ -175,18 +186,7 @@ nonisolated struct WebSurfaceSelectionReader { return retainedSelection; }; - const clearIfDetachedFrame = () => { - if (!retainedDocument || retainedDocument === document) return; - try { - const frame = retainedDocument.defaultView?.frameElement; - if (!frame || !frame.isConnected) clear(); - } catch (_) { - clear(); - } - }; - const trackedDocuments = new WeakSet(); - const documentObservers = new WeakMap(); const selectionChangingKeys = new Set([ 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'Backspace', 'Delete', 'End', 'Enter', 'Escape', @@ -253,19 +253,10 @@ nonisolated struct WebSurfaceSelectionReader { 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); - // This observer only discovers newly inserted frames and detects a - // detached selected iframe. Selection content is revalidated lazily - // by `read`, so unrelated page mutations stay observable. - const observer = new MutationObserver((records) => { - if (records.length > 0) clearIfDetachedFrame(); - for (const record of records) { - for (const node of record.addedNodes || []) scanFrames(node); - } - }); - observer.observe(targetDocument, { childList: true, subtree: true }); - documentObservers.set(targetDocument, observer); - const targetWindow = targetDocument.defaultView; targetWindow?.addEventListener('beforeunload', () => { if (targetDocument === document) { @@ -273,12 +264,19 @@ nonisolated struct WebSurfaceSelectionReader { } else { clear(targetDocument); } - documentObservers.get(targetDocument)?.disconnect(); - documentObservers.delete(targetDocument); }, true); }; - globalThis.__cmuxSurfaceSelectionRuntime = { read }; + // 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, '__cmuxSurfaceSelectionRuntime', { + configurable: false, + enumerable: false, + value: runtime, + writable: false + }); installDocument(document); capture(window); return true; diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index 5962ead40d2..fbedb8a4883 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -203,9 +203,11 @@ extension TerminalController { continuation.yield(self.v2Result(id: request.id?.foundationObject, result)) continuation.finish() } + let deadlineClock = ContinuousClock() let timeout = Task { do { - try await Task.sleep(for: .seconds(5)) + // Genuine request deadline; cancellation tears down the sleeper. + try await deadlineClock.sleep(for: .seconds(5)) } catch { return } From cecaf8479579d03d4b2726a22960acd2e413a699 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 17:09:16 -0700 Subject: [PATCH 27/44] Harden selection bridge and terminal capture --- Sources/Panels/TerminalPanel.swift | 17 ++++++++++++- .../Panels/WebSurfaceSelectionReader.swift | 25 +++++++++++++------ docs/cli-contract.md | 6 +++-- 3 files changed, 38 insertions(+), 10 deletions(-) diff --git a/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index f2dea2d46a6..a56b909570c 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -118,13 +118,28 @@ final class TerminalPanel: Panel, ObservableObject { return .unavailable } + return await Self.readSelection(from: liveSurface) + } + + #if compiler(>=6.2) + @concurrent + #else + @Sendable + #endif + private nonisolated static func readSelection( + from liveSurface: ghostty_surface_t + ) async -> SurfaceSelectionReadResult { + guard ghostty_surface_has_selection(liveSurface) else { + return .snapshot(.none(kind: .terminal)) + } + var selection = ghostty_text_s() guard ghostty_surface_read_selection_clipboard_text( liveSurface, UInt(SurfaceSelectionSnapshot.maximumTextBytes), &selection ) else { - return .snapshot(.none(kind: .terminal)) + return .unavailable } defer { ghostty_surface_free_text(liveSurface, &selection) } diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index ae094906b64..4b1c83a289f 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -15,7 +15,10 @@ nonisolated struct WebSurfaceSelectionReader { private static let trackingBootstrapScript = """ (() => { - if (globalThis.__cmuxSurfaceSelectionRuntime) return true; + 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 boundedText = (text) => { @@ -23,14 +26,19 @@ nonisolated struct WebSurfaceSelectionReader { if (value.length <= maxTextCharacters) return value; return value.slice(0, maxTextCharacters - 1) + '…'; }; - const empty = () => ({ has_selection: false, text: '' }); - const unreadable = () => ({ has_selection: false, text: '', blocks_fallback: true }); - const selected = (text, sourceDocument = null, metadata = {}) => ({ + const empty = () => Object.freeze({ has_selection: false, text: '' }); + const unreadable = () => Object.freeze({ + has_selection: false, + text: '', + blocks_fallback: 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) { @@ -51,7 +59,11 @@ nonisolated struct WebSurfaceSelectionReader { if (activeTag === 'iframe' || activeTag === 'frame') { try { const childWindow = active.contentWindow; - return childWindow ? readLiveSelection(childWindow) : unreadable(); + if (!childWindow) return unreadable(); + try { + installDocument?.(childWindow.document); + } catch (_) {} + return readLiveSelection(childWindow); } catch (_) { return unreadable(); } @@ -197,7 +209,6 @@ nonisolated struct WebSurfaceSelectionReader { if (selectionChangingKeys.has(key)) return true; return key.length === 1 && !event?.metaKey && !event?.ctrlKey; }; - let installDocument; const installFrame = (frame) => { try { if (frame?.contentDocument) installDocument(frame.contentDocument); @@ -271,7 +282,7 @@ nonisolated struct WebSurfaceSelectionReader { // 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, '__cmuxSurfaceSelectionRuntime', { + Object.defineProperty(globalThis, runtimeKey, { configurable: false, enumerable: false, value: runtime, diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 5b4525b0bf6..2d6d1cdda66 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -209,8 +209,10 @@ Successful responses use one shape across surface kinds: ``` - `has_selection`, `kind`, `text`, and `base64` are always present. -- Selection text is capped at 1 MiB; an oversized selection is shortened with a - visible ellipsis before it crosses the socket boundary. +- 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 From 03984fd0519daba557564fa1a586efe6212709b2 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 17:14:06 -0700 Subject: [PATCH 28/44] Bound WebKit selection extraction --- .../Panels/WebSurfaceSelectionReader.swift | 77 +++++++++++++++++-- 1 file changed, 71 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 4b1c83a289f..a7a6a657b66 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -26,6 +26,68 @@ nonisolated struct WebSurfaceSelectionReader { if (value.length <= maxTextCharacters) return value; return value.slice(0, 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); + return source.slice(safeStart, safeStart + maxTextCharacters - 1) + '…'; + }; + // 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 truncated = false; + let node = root.nodeType === 3 ? root : walker.nextNode(); + while (node) { + 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, 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 = () => Object.freeze({ has_selection: false, @@ -78,7 +140,7 @@ nonisolated struct WebSurfaceSelectionReader { typeof active.selectionEnd === 'number' && active.selectionEnd > active.selectionStart) { return selected( - String(active.value || '').slice(active.selectionStart, active.selectionEnd), + boundedControlText(active.value, active.selectionStart, active.selectionEnd), targetDocument, { selection_control: active, @@ -93,9 +155,11 @@ nonisolated struct WebSurfaceSelectionReader { const selection = targetWindow.getSelection(); if (selection && selection.rangeCount > 0 && !selection.isCollapsed) { try { - return selected(selection.toString(), targetDocument, { - selection_range: selection.getRangeAt(0).cloneRange() - }); + const range = selection.getRangeAt(0).cloneRange(); + const text = boundedRangeText(range); + return text === null + ? unreadable() + : selected(text, targetDocument, { selection_range: range }); } catch (_) { return unreadable(); } @@ -157,7 +221,8 @@ nonisolated struct WebSurfaceSelectionReader { retainedRange.endContainer?.isConnected === false) { return false; } - return boundedText(retainedRange.toString()) === retainedSelection.text; + const text = boundedRangeText(retainedRange); + return text !== null && text === retainedSelection.text; } catch (_) { return false; } @@ -167,7 +232,7 @@ nonisolated struct WebSurfaceSelectionReader { if (retainedControl.isConnected === false) return false; const start = Number(retainedControlStart); const end = Number(retainedControlEnd); - return boundedText(String(retainedControl.value || '').slice(start, end)) === retainedSelection.text; + return boundedControlText(retainedControl.value, start, end) === retainedSelection.text; } catch (_) { return false; } From ce4df2fa325aa58c9ba14aa366723dbb44a0ba78 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 17:36:43 -0700 Subject: [PATCH 29/44] Serialize terminal selection reads with teardown --- ...rminalSurfaceRuntimeSelectionRequest.swift | 40 ++++++++++++++++ ...RuntimeTeardownCoordinator+Selection.swift | 8 ++++ .../Surface/TerminalSurface+Selection.swift | 22 +++++++++ .../TerminalSurfaceSelectionRead.swift | 11 +++++ Sources/Panels/TerminalPanel.swift | 48 ++++--------------- 5 files changed, 91 insertions(+), 38 deletions(-) create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeSelectionRequest.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Lifecycle/TerminalSurfaceRuntimeTeardownCoordinator+Selection.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Selection.swift create mode 100644 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurfaceSelectionRead.swift 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/Sources/Panels/TerminalPanel.swift b/Sources/Panels/TerminalPanel.swift index a56b909570c..e5d577d05f1 100644 --- a/Sources/Panels/TerminalPanel.swift +++ b/Sources/Panels/TerminalPanel.swift @@ -112,47 +112,19 @@ final class TerminalPanel: Panel, ObservableObject { } func readSurfaceSelection() async -> SurfaceSelectionReadResult { - guard let liveSurface = surface.liveSurfaceForGhosttyAccess( - reason: "readSurfaceSelection" - ) else { - return .unavailable - } - - return await Self.readSelection(from: liveSurface) - } - - #if compiler(>=6.2) - @concurrent - #else - @Sendable - #endif - private nonisolated static func readSelection( - from liveSurface: ghostty_surface_t - ) async -> SurfaceSelectionReadResult { - guard ghostty_surface_has_selection(liveSurface) else { + switch await surface.readSelection( + maxBytes: SurfaceSelectionSnapshot.maximumTextBytes + ) { + case .none: return .snapshot(.none(kind: .terminal)) - } - - var selection = ghostty_text_s() - guard ghostty_surface_read_selection_clipboard_text( - liveSurface, - UInt(SurfaceSelectionSnapshot.maximumTextBytes), - &selection - ) else { + case .selected(let text): + return .snapshot(.selected( + kind: .terminal, + text: SurfaceSelectionSnapshot.boundedText(text) + )) + case .unavailable: return .unavailable } - defer { ghostty_surface_free_text(liveSurface, &selection) } - - let text: String - if let bytes = selection.text, selection.text_len > 0 { - text = String( - decoding: Data(bytes: bytes, count: Int(selection.text_len)), - as: UTF8.self - ) - } else { - text = "" - } - return .snapshot(.selected(kind: .terminal, text: text)) } func updateShellActivityState(_ state: PanelShellActivityState) { From ef89d3d31e9f0d1be6ab6a0ea629e4d20ec7afaa Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 17:41:51 -0700 Subject: [PATCH 30/44] Harden selection clearing and Unicode bounds --- Sources/Panels/SurfaceSelectionSnapshot.swift | 9 +++- .../Panels/WebSurfaceSelectionReader.swift | 6 ++- .../TerminalController+SurfaceSelection.swift | 11 +++++ cmuxTests/SurfaceSelectionTests.swift | 44 +++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/SurfaceSelectionSnapshot.swift b/Sources/Panels/SurfaceSelectionSnapshot.swift index 69bdb24948d..ebc23b2f225 100644 --- a/Sources/Panels/SurfaceSelectionSnapshot.swift +++ b/Sources/Panels/SurfaceSelectionSnapshot.swift @@ -22,7 +22,14 @@ public nonisolated struct SurfaceSelectionSnapshot: Equatable, Sendable { guard text.utf8.count > maximumTextBytes else { return text } let marker = "…" let budget = max(0, maximumTextBytes - marker.utf8.count) - return String(decoding: text.utf8.prefix(budget), as: UTF8.self) + marker + 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. diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index a7a6a657b66..7c2868a49e0 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -292,7 +292,11 @@ nonisolated struct WebSurfaceSelectionReader { trackedDocuments.add(targetDocument); const captureDocument = () => { const targetWindow = targetDocument.defaultView; - if (targetWindow) capture(targetWindow); + if (!targetWindow) return; + // A collapsed selectionchange while the document is unfocused is + // WebKit's native-focus handoff signal. When the page still owns + // focus, an empty range is an intentional page-side clear. + capture(targetWindow, targetDocument.hasFocus()); }; const reconcileInput = () => { const targetWindow = targetDocument.defaultView; diff --git a/Sources/TerminalController+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift index ee3721410a3..04426efc450 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -80,6 +80,16 @@ extension TerminalController { // 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, + 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 { @@ -89,6 +99,7 @@ extension TerminalController { guard !trimmed.isEmpty, UUID(uuidString: trimmed) == nil else { return false } + guard isOpaqueHandleReference(trimmed) else { return false } return v2MainSync { v2ResolveHandleRef(trimmed) } == nil } if hasUnresolvedOpaqueSelector { diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index e593ea34240..73424227a8c 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -30,6 +30,12 @@ struct SurfaceSelectionTests { ) #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 { @@ -426,6 +432,44 @@ struct SurfaceSelectionTests { 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( + """ + (() => { + 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) } private func snapshot( From d58f9ca8536cd36625707af9070876d8f9d63da4 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 18:01:42 -0700 Subject: [PATCH 31/44] Harden selection routing and WebKit deadlines --- Resources/Localizable.xcstrings | 7 ++ .../Panels/WebSurfaceSelectionReader.swift | 107 ++++++++++++++---- ...erminalController+ControlSocketAsync.swift | 5 +- .../TerminalController+SurfaceSelection.swift | 3 +- 4 files changed, 99 insertions(+), 23 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cf815fbbe8d..2b8953a747a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -120,6 +120,13 @@ "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": { diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 7c2868a49e0..843c2b02415 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -3,6 +3,24 @@ import WebKit /// Reads DOM or editable-control selection without changing focus or page state. nonisolated struct WebSurfaceSelectionReader { + @MainActor + private final class EvaluationCancellationTarget: @unchecked Sendable { + // The weak WebView is only read or mutated on MainActor. The unchecked + // conformance lets the cancellation callback schedule that hop without + // retaining the WebView after its panel has replaced it. + weak var webView: WKWebView? + + init(webView: WKWebView) { + self.webView = webView + } + + func cancel() { + if webView?.isLoading == true { + webView?.stopLoading() + } + } + } + private nonisolated struct Payload: Decodable { let hasSelection: Bool let text: String @@ -21,10 +39,22 @@ nonisolated struct WebSurfaceSelectionReader { if (Object.prototype.hasOwnProperty.call(globalThis, runtimeKey)) return false; 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, maxTextCharacters - 1) + '…'; + return value.slice(0, unicodeSafeEnd(value, maxTextCharacters - 1)) + '…'; }; const boundedControlText = (value, start, end) => { const source = String(value || ''); @@ -32,7 +62,8 @@ nonisolated struct WebSurfaceSelectionReader { const safeEnd = Math.max(safeStart, Math.min(source.length, Number(end) || 0)); const length = safeEnd - safeStart; if (length <= maxTextCharacters) return source.slice(safeStart, safeEnd); - return source.slice(safeStart, safeStart + maxTextCharacters - 1) + '…'; + 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 @@ -72,7 +103,7 @@ nonisolated struct WebSurfaceSelectionReader { const count = Math.min(remaining, end - start); used += count; if (count < end - start) { - boundedRange.setEnd(node, start + count); + boundedRange.setEnd(node, unicodeSafeEnd(value, start + count)); truncated = true; break; } @@ -363,21 +394,40 @@ nonisolated struct WebSurfaceSelectionReader { })() """ + private static let evaluationTimeoutMilliseconds = 4_000 private static let script = """ - (() => { - const runtime = globalThis.__cmuxSurfaceSelectionRuntime; - if (!runtime || typeof runtime.read !== 'function') return null; - const maxTextCharacters = \(SurfaceSelectionSnapshot.maximumTextBytes / 4); - const boundedText = (text) => { - const value = String(text || ''); - if (value.length <= maxTextCharacters) return value; - return value.slice(0, maxTextCharacters - 1) + '…'; + (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) : '' + }); }; - const result = runtime.read(); - return JSON.stringify({ - has_selection: result?.has_selection === true, - text: result?.has_selection === true ? boundedText(result.text) : '' - }); + return await Promise.race([ + Promise.resolve().then(readSelection), + new Promise((resolve) => setTimeout(() => resolve(null), \(Self.evaluationTimeoutMilliseconds))) + ]); })() """ @@ -401,11 +451,26 @@ nonisolated struct WebSurfaceSelectionReader { url: String? = nil ) async -> SurfaceSelectionReadResult { do { - guard let encoded = try await webView.evaluateJavaScript( - Self.script, - contentWorld: .page - ) as? String, - let data = encoded.data(using: .utf8) else { + let cancellationTarget = EvaluationCancellationTarget(webView: webView) + guard let encoded = try await withTaskCancellationHandler(operation: { + try await webView.callAsyncJavaScript( + Self.script, + arguments: [:], + in: nil, + contentWorld: .page + ) as? String + }, onCancel: { + Task { @MainActor in + // WebKit has no cancellable evaluation handle. Stopping + // an active navigation releases the page-side request + // when a socket deadline cancels this reader; the in-page + // Promise timeout bounds normal evaluations independently. + cancellationTarget.cancel() + } + }) else { + return .unavailable + } + guard let data = encoded.data(using: .utf8) else { return .unavailable } let payload = try JSONDecoder().decode(Payload.self, from: data) diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index fbedb8a4883..02dfd3460db 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -215,7 +215,10 @@ extension TerminalController { continuation.yield(self.v2Error( id: request.id?.foundationObject, code: "timeout", - message: "Request timed out after 5 seconds" + message: String( + localized: "socket.surfaceSelection.timeout", + defaultValue: "Request timed out after 5 seconds" + ) )) continuation.finish() } diff --git a/Sources/TerminalController+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift index 04426efc450..6ea27c2a6bb 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -83,7 +83,8 @@ extension TerminalController { let isOpaqueHandleReference: (String) -> Bool = { raw in let pieces = raw.split(separator: ":", omittingEmptySubsequences: false) guard pieces.count == 2, - ControlHandleKind(rawValue: String(pieces[0]).lowercased()) != nil, + (ControlHandleKind(rawValue: String(pieces[0]).lowercased()) != nil + || String(pieces[0]).lowercased() == "tab"), let ordinal = Int(pieces[1]), ordinal > 0 else { return false From 65aee70e406c929d84033cadec30e62ac572c7ac Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 18:05:46 -0700 Subject: [PATCH 32/44] Split surface selection test support --- cmux.xcodeproj/project.pbxproj | 6 +- .../SurfaceSelectionNavigationLoader.swift | 63 +++++++++++++++++++ cmuxTests/SurfaceSelectionTests.swift | 54 ---------------- 3 files changed, 68 insertions(+), 55 deletions(-) create mode 100644 cmuxTests/SurfaceSelectionNavigationLoader.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b0c6c38e7eb..f64bc273952 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2250,6 +2250,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources 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 */; }; @@ -5073,6 +5074,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 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>"; }; @@ -8291,6 +8293,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C0DE75890000000000000004 /* DeflatedAssetTestSupport.swift */, 1590EE4F01FEF02D40A48698 /* CLIExplicitSurfaceRoutingTests.swift */, A1002000000000000000000A /* SurfaceSelectionTests.swift */, + A10020000000000000000018 /* SurfaceSelectionNavigationLoader.swift */, A7367002A1B2C3D4E5F60718 /* CLISSHSessionAttachAnchorTests.swift */, C0DEBACC0000000000000004 /* CodexTeamsAppServerFixture.swift */, A10240050000000000000001 /* CodexTeamsAppServerProcessTests.swift */, @@ -11791,7 +11794,8 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 842300000000000000000007 /* SurfaceResumeAgentBindingGenerationTests.swift in Sources */, F6572012A1B2C3D4E5F60718 /* SurfaceResumeBindingCodexUpdateCheckTests.swift in Sources */, 842300000000000000000001 /* SurfaceResumeExitedAgentLivenessTests.swift in Sources */, - A10020000000000000000009 /* SurfaceSelectionTests.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/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 index 73424227a8c..246edc62f42 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -482,60 +482,6 @@ struct SurfaceSelectionTests { } } -private enum SurfaceSelectionTestError: Error { - case expectedSnapshot - case navigationFailed(String) -} - private final class SurfaceSelectionFirstResponderView: NSView { override var acceptsFirstResponder: Bool { true } } - -@MainActor -private 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) - } -} From 577cbb23f0e96b0f698ac825db45f18768b21739 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 18:29:22 -0700 Subject: [PATCH 33/44] Bound selection evaluation and relay ownership --- Sources/Panels/BrowserPanel.swift | 3 +- Sources/Panels/MarkdownWebRenderer.swift | 3 +- .../WebSurfaceSelectionEvaluationOwner.swift | 60 ++++++++++++++ .../Panels/WebSurfaceSelectionReader.swift | 78 ++++++------------- .../TerminalController+SurfaceSelection.swift | 56 +++++++++++++ cmux.xcodeproj/project.pbxproj | 4 + 6 files changed, 149 insertions(+), 55 deletions(-) create mode 100644 Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index abc5df85cfe..d646184a88f 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2000,6 +2000,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>? @@ -7004,7 +7005,7 @@ extension BrowserPanel { webView.backForwardList.currentItem != nil else { return .snapshot(.none(kind: .browser, url: url)) } - return await WebSurfaceSelectionReader().read( + return await surfaceSelectionReader.read( webView: webView, kind: .browser, url: url diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index bc8c98e6f44..7c367218c4f 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -145,6 +145,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { @MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler, WKURLSchemeHandler { var webView: MarkdownWebView? + private let surfaceSelectionReader = WebSurfaceSelectionReader() var panelId: UUID = UUID() var workspaceId: UUID = UUID() var filePath: String = "" @@ -351,7 +352,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { guard isLoaded, let webView else { return .snapshot(.none(kind: .markdown, filePath: normalizedPath)) } - return await WebSurfaceSelectionReader().read( + return await surfaceSelectionReader.read( webView: webView, kind: .markdown, filePath: normalizedPath diff --git a/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift b/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift new file mode 100644 index 00000000000..2e56f76efbe --- /dev/null +++ b/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift @@ -0,0 +1,60 @@ +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 + 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 index 843c2b02415..788680269fc 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -2,24 +2,9 @@ import Foundation import WebKit /// Reads DOM or editable-control selection without changing focus or page state. -nonisolated struct WebSurfaceSelectionReader { - @MainActor - private final class EvaluationCancellationTarget: @unchecked Sendable { - // The weak WebView is only read or mutated on MainActor. The unchecked - // conformance lets the cancellation callback schedule that hop without - // retaining the WebView after its panel has replaced it. - weak var webView: WKWebView? - - init(webView: WKWebView) { - self.webView = webView - } - - func cancel() { - if webView?.isLoading == true { - webView?.stopLoading() - } - } - } +@MainActor +final class WebSurfaceSelectionReader { + private let evaluationOwner = WebSurfaceSelectionEvaluationOwner() private nonisolated struct Payload: Decodable { let hasSelection: Bool @@ -39,6 +24,7 @@ nonisolated struct WebSurfaceSelectionReader { 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) { @@ -75,9 +61,11 @@ nonisolated struct WebSurfaceSelectionReader { 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; @@ -321,17 +309,19 @@ nonisolated struct WebSurfaceSelectionReader { installDocument = (targetDocument) => { if (!targetDocument || trackedDocuments.has(targetDocument)) return; trackedDocuments.add(targetDocument); + let captureQueued = false; const captureDocument = () => { - const targetWindow = targetDocument.defaultView; - if (!targetWindow) return; - // A collapsed selectionchange while the document is unfocused is - // WebKit's native-focus handoff signal. When the page still owns - // focus, an empty range is an intentional page-side clear. - capture(targetWindow, targetDocument.hasFocus()); - }; - const reconcileInput = () => { - const targetWindow = targetDocument.defaultView; - if (targetWindow) capture(targetWindow, true); + if (captureQueued) return; + captureQueued = true; + queueMicrotask(() => { + captureQueued = false; + const targetWindow = targetDocument.defaultView; + if (!targetWindow) return; + // A collapsed selectionchange while the document is unfocused is + // WebKit's native-focus handoff signal. When the page still owns + // focus, an empty range is an intentional page-side clear. + capture(targetWindow, targetDocument.hasFocus()); + }); }; const clearForInteraction = () => clear(); // A collapsed selectionchange is not itself a clear signal: WebKit @@ -347,7 +337,7 @@ nonisolated struct WebSurfaceSelectionReader { if (keyChangesSelection(event)) clear(); }, true); targetDocument.addEventListener('focusin', captureDocument, true); - targetDocument.addEventListener('input', reconcileInput, true); + targetDocument.addEventListener('input', captureDocument, true); const invalidateForNavigation = () => clear(targetDocument); targetDocument.defaultView?.addEventListener('hashchange', invalidateForNavigation, true); targetDocument.defaultView?.addEventListener('popstate', invalidateForNavigation, true); @@ -450,29 +440,13 @@ nonisolated struct WebSurfaceSelectionReader { 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 cancellationTarget = EvaluationCancellationTarget(webView: webView) - guard let encoded = try await withTaskCancellationHandler(operation: { - try await webView.callAsyncJavaScript( - Self.script, - arguments: [:], - in: nil, - contentWorld: .page - ) as? String - }, onCancel: { - Task { @MainActor in - // WebKit has no cancellable evaluation handle. Stopping - // an active navigation releases the page-side request - // when a socket deadline cancels this reader; the in-page - // Promise timeout bounds normal evaluations independently. - cancellationTarget.cancel() - } - }) else { - return .unavailable - } - guard let data = encoded.data(using: .utf8) else { - return .unavailable - } let payload = try JSONDecoder().decode(Payload.self, from: data) let normalizedPath = filePath.map { URL(fileURLWithPath: $0).standardizedFileURL.path @@ -490,8 +464,6 @@ nonisolated struct WebSurfaceSelectionReader { filePath: normalizedPath, url: url )) - } catch is CancellationError { - return .unavailable } catch { return .unavailable } diff --git a/Sources/TerminalController+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift index 6ea27c2a6bb..3712921e919 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -68,6 +68,23 @@ extension TerminalController { } 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", @@ -140,6 +157,23 @@ extension TerminalController { ) ) } + 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( @@ -250,6 +284,17 @@ extension TerminalController { 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): @@ -280,6 +325,17 @@ extension TerminalController { ) } + 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 { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f64bc273952..b00a5f6a7fe 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2635,6 +2635,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */; }; C37800000000000000000003 /* VMDefaultCloudCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */; }; C37800000000000000000001 /* VMSSHCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C37800000000000000000002 /* VMSSHCommandTests.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 */; }; @@ -5459,6 +5460,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef 9CEC6F0035B71AE59FA45BA7 /* VMClientSocketCommands.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VMClientSocketCommands.swift; sourceTree = "<group>"; }; C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMDefaultCloudCommandTests.swift; sourceTree = "<group>"; }; C37800000000000000000002 /* VMSSHCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMSSHCommandTests.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>"; }; @@ -7176,6 +7178,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A1002000000000000000000E /* SurfaceSelectionReadResult.swift */, A1002000000000000000000C /* SurfaceSelectionSnapshot.swift */, A10020000000000000000012 /* WebSurfaceSelectionReader.swift */, + A1002000000000000000001A /* WebSurfaceSelectionEvaluationOwner.swift */, A85630010000000000000001 /* WorkspaceAttentionColor.swift */, C54860060000000000000002 /* PanelStableSurfaceIdentity.swift */, A5F0C0010000000000000001 /* WorkspaceAttentionFlashRingView.swift */, @@ -10822,6 +10825,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A28B087F0000000000000005 /* ViewerNavigationKeyRouter.swift in Sources */, ACE4FE1A8F1CC6C0B6A673AB /* VMClient.swift in Sources */, ACE4FE2A8F1CC6C0B6A673AB /* VMClientSocketCommands.swift in Sources */, + A10020000000000000000019 /* WebSurfaceSelectionEvaluationOwner.swift in Sources */, A10020000000000000000011 /* WebSurfaceSelectionReader.swift in Sources */, A500120C /* WindowAccessor.swift in Sources */, 9065A0020000000000000001 /* WindowAppKitCapture.swift in Sources */, From efac5904f6436ab169f7fac7751293cbd115f2c7 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 18:44:39 -0700 Subject: [PATCH 34/44] Close canceled selection evaluation race --- Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift b/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift index 2e56f76efbe..3286dde42b2 100644 --- a/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift +++ b/Sources/Panels/WebSurfaceSelectionEvaluationOwner.swift @@ -18,6 +18,10 @@ final class WebSurfaceSelectionEvaluationOwner { 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( From 6ead2e4cc2864c1f891153882a4d0bcb8819233a Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Tue, 25 Aug 2026 19:29:55 -0700 Subject: [PATCH 35/44] Fix selection timeout script type reference --- Sources/Panels/WebSurfaceSelectionReader.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 788680269fc..f9a1fa39ca0 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -416,7 +416,7 @@ final class WebSurfaceSelectionReader { }; return await Promise.race([ Promise.resolve().then(readSelection), - new Promise((resolve) => setTimeout(() => resolve(null), \(Self.evaluationTimeoutMilliseconds))) + new Promise((resolve) => setTimeout(() => resolve(null), \(WebSurfaceSelectionReader.evaluationTimeoutMilliseconds))) ]); })() """ From 60aa8ffabada26af2f6f24af1a69d3428a286ca5 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Thu, 27 Aug 2026 15:20:40 -0700 Subject: [PATCH 36/44] Align Bonsplit revision with current main APIs --- vendor/bonsplit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/bonsplit b/vendor/bonsplit index 18f3f8bc2ac..874313e924a 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 18f3f8bc2ac3019d0f30b41586f4785950af6de7 +Subproject commit 874313e924ad0ee0fc8a28c698fd2b2b62e259a2 From c0ce9d89f59a19a422f6905e734579fe5f159edc Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Thu, 27 Aug 2026 16:10:22 -0700 Subject: [PATCH 37/44] Harden merged surface selection lifecycle --- .../ControlCommandCoordinator.swift | 15 + .../Wire/ControlHandleRegistry.swift | 26 +- .../ControlHandleRegistryTests.swift | 12 + .../WorkspaceSurfaceListModel.swift | 46 ++- .../WorkspaceSurfaceListModelTests.swift | 38 +++ Sources/GhosttyTerminalAppearance.swift | 1 + Sources/Panels/FilePreviewPanel.swift | 4 +- Sources/Panels/MarkdownPanel.swift | 4 +- .../NativeTextSurfaceSelectionReader.swift | 314 +++++++++++++++--- .../Panels/WebSurfaceSelectionReader.swift | 54 ++- ...minalController+ControlReadSnapshots.swift | 6 + .../TerminalController+SurfaceSelection.swift | 13 +- Sources/TerminalController.swift | 24 +- Sources/Workspace.swift | 8 +- ...gentSessionIndexCodexWeakRecordTests.swift | 1 + cmuxTests/SurfaceSelectionTests.swift | 6 + 16 files changed, 496 insertions(+), 76 deletions(-) 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/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/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/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/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/FilePreviewPanel.swift b/Sources/Panels/FilePreviewPanel.swift index e09ba12c4fc..6e44f82b055 100644 --- a/Sources/Panels/FilePreviewPanel.swift +++ b/Sources/Panels/FilePreviewPanel.swift @@ -1177,6 +1177,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 @@ -1243,6 +1244,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan stopWatchingForFileChanges() textLoadCoordinator.cancel() modeLoadCoordinator.cancel() + selectionReader.close() nativeViewSessions.closeAll() textView = nil focusCoordinator.unregisterAll() @@ -1250,7 +1252,7 @@ final class FilePreviewPanel: Panel, ObservableObject, FilePreviewTextEditingPan func readSurfaceSelection() async -> SurfaceSelectionReadResult { guard previewMode == .text else { return .unsupported } - return .snapshot(await NativeTextSurfaceSelectionReader().read( + return .snapshot(await selectionReader.read( textView: textView, kind: .filePreview, filePath: filePath diff --git a/Sources/Panels/MarkdownPanel.swift b/Sources/Panels/MarkdownPanel.swift index 5409b74efab..a79bab1f40a 100644 --- a/Sources/Panels/MarkdownPanel.swift +++ b/Sources/Panels/MarkdownPanel.swift @@ -84,6 +84,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel private var activeSaveGeneration: Int? private var pendingSearchNeedle: String? 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? @@ -245,6 +246,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel func close() { isClosed = true rendererSession.close() + selectionReader.close() GlobalSearchCoordinator.shared.purgePanel(id: id) textView = nil stopWatching() @@ -271,7 +273,7 @@ final class MarkdownPanel: Panel, ObservableObject, FilePreviewTextEditingPanel func readSurfaceSelection() async -> SurfaceSelectionReadResult { switch displayMode { case .text: - return .snapshot(await NativeTextSurfaceSelectionReader().read( + return .snapshot(await selectionReader.read( textView: textView, kind: .markdown, filePath: filePath diff --git a/Sources/Panels/NativeTextSurfaceSelectionReader.swift b/Sources/Panels/NativeTextSurfaceSelectionReader.swift index 04b45bf6922..b74a67c0c3a 100644 --- a/Sources/Panels/NativeTextSurfaceSelectionReader.swift +++ b/Sources/Panels/NativeTextSurfaceSelectionReader.swift @@ -2,36 +2,237 @@ import AppKit import Foundation /// Maps an AppKit UTF-16 selection to source text and one-based line numbers. -nonisolated struct NativeTextSurfaceSelectionReader { - @MainActor +@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 else { + guard let textView, let textStorage = textView.textStorage else { return .none(kind: kind, filePath: normalizedPath) } - let source = textView.string as NSString + 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 <= source.length, - selectedRange.length <= source.length - selectedRange.location else { + selectedRange.location <= sourceLength, + selectedRange.length <= sourceLength - selectedRange.location else { return .none(kind: kind, filePath: normalizedPath) } - - return await Self.makeSnapshot( - source: source as String, - selectedLocation: selectedRange.location, - selectedLength: selectedRange.length, + 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 @@ -41,47 +242,78 @@ nonisolated struct NativeTextSurfaceSelectionReader { source: String, selectedLocation: Int, selectedLength: Int, + lineIndex: LineIndex?, kind: PanelType, filePath: String ) async -> SurfaceSelectionSnapshot { - let source = source as NSString - let startLine = lineNumber(atUTF16Offset: selectedLocation, in: source) - let endLine = lineNumber( - atUTF16Offset: selectedLocation + selectedLength - 1, - in: source + 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 boundedLength = min( - selectedLength, - SurfaceSelectionSnapshot.maximumTextBytes + 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 ) - let boundedSelection = source.substring( - with: NSRange(location: selectedLocation, length: boundedLength) + } + + 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(boundedSelection), - filePath: filePath, - lineRange: SurfaceSelectionLineRange(start: startLine, end: endLine) + text: SurfaceSelectionSnapshot.boundedText(selectedText), + filePath: filePath ) } - private nonisolated static func lineNumber(atUTF16Offset offset: Int, in source: NSString) -> Int { - guard offset > 0 else { return 1 } - - var lineNumber = 1 - var cursor = 0 - while cursor < offset { - var lineEnd = 0 - source.getLineStart( - nil, - end: &lineEnd, - contentsEnd: nil, - for: NSRange(location: cursor, length: 0) - ) - guard lineEnd > cursor, lineEnd <= offset else { break } - lineNumber += 1 - cursor = lineEnd + 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 lineNumber + return max(1, low) } } diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index f9a1fa39ca0..9b44fa57f1a 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -9,10 +9,12 @@ final class WebSurfaceSelectionReader { 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" } } @@ -108,10 +110,15 @@ final class WebSurfaceSelectionReader { } }; const empty = () => Object.freeze({ has_selection: false, text: '' }); - const unreadable = () => Object.freeze({ + const unreadable = (sourceDocument = null, sourceFrame = null) => Object.freeze({ has_selection: false, text: '', - blocks_fallback: true + 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, @@ -127,12 +134,12 @@ final class WebSurfaceSelectionReader { } return active; }; - const readLiveSelection = (targetWindow) => { + const readLiveSelection = (targetWindow, fallbackDocument = null, fallbackFrame = null) => { let targetDocument; try { targetDocument = targetWindow.document; } catch (_) { - return unreadable(); + return unreadable(fallbackDocument, fallbackFrame); } const active = deepestActiveElement(targetDocument); @@ -140,20 +147,20 @@ final class WebSurfaceSelectionReader { if (activeTag === 'iframe' || activeTag === 'frame') { try { const childWindow = active.contentWindow; - if (!childWindow) return unreadable(); + if (!childWindow) return unreadable(targetDocument, active); try { installDocument?.(childWindow.document); } catch (_) {} - return readLiveSelection(childWindow); + return readLiveSelection(childWindow, targetDocument, active); } catch (_) { - return unreadable(); + return unreadable(targetDocument, active); } } const isInput = activeTag === 'input'; const isTextControl = isInput || activeTag === 'textarea'; const isPassword = isInput && String(active.type || '').toLowerCase() === 'password'; - if (isPassword) return unreadable(); + if (isPassword) return privacyBlocked(); if (isTextControl) { if (typeof active.selectionStart === 'number' && typeof active.selectionEnd === 'number' && @@ -177,10 +184,10 @@ final class WebSurfaceSelectionReader { const range = selection.getRangeAt(0).cloneRange(); const text = boundedRangeText(range); return text === null - ? unreadable() + ? unreadable(targetDocument) : selected(text, targetDocument, { selection_range: range }); } catch (_) { - return unreadable(); + return unreadable(targetDocument); } } return empty(); @@ -196,20 +203,20 @@ final class WebSurfaceSelectionReader { }; 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 !== sourceDocument) return; + if (sourceDocument && retainedDocument && retainedDocument !== sourceDocument) return; retainedSelection = empty(); retainedDocument = null; retainedLocation = null; retainedRange = null; retainedControl = null; - retainedControlStart = null; - retainedControlEnd = null; + retainedControlStart = null; retainedControlEnd = null; retainedFrame = null; }; const retain = (live) => { retainedSelection = selected(live.text); @@ -218,11 +225,23 @@ final class WebSurfaceSelectionReader { retainedRange = live.selection_range || null; retainedControl = live.selection_control || null; retainedControlStart = live.selection_start ?? null; - retainedControlEnd = live.selection_end ?? 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); @@ -259,6 +278,7 @@ final class WebSurfaceSelectionReader { return true; }; const clearIfDetachedFrame = () => { + if (retainedFrame && !retainedFrame.isConnected) { clear(); return; } if (!retainedDocument || retainedDocument === document) return; try { const frame = retainedDocument.defaultView?.frameElement; @@ -411,7 +431,8 @@ final class WebSurfaceSelectionReader { const result = runtime.read(); return JSON.stringify({ has_selection: result?.has_selection === true, - text: result?.has_selection === true ? boundedText(result.text) : '' + text: result?.has_selection === true ? boundedText(result.text) : '', + blocks_fallback: result?.blocks_fallback === true }); }; return await Promise.race([ @@ -451,6 +472,9 @@ final class WebSurfaceSelectionReader { let normalizedPath = filePath.map { URL(fileURLWithPath: $0).standardizedFileURL.path } + if payload.blocksFallback { + return .unavailable + } if payload.hasSelection { return .snapshot(.selected( kind: kind, 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+SurfaceSelection.swift b/Sources/TerminalController+SurfaceSelection.swift index 3712921e919..03845d1c3e0 100644 --- a/Sources/TerminalController+SurfaceSelection.swift +++ b/Sources/TerminalController+SurfaceSelection.swift @@ -121,7 +121,18 @@ extension TerminalController { return v2MainSync { v2ResolveHandleRef(trimmed) } == nil } if hasUnresolvedOpaqueSelector { - v2RefreshKnownRefs() + 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 diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 3d0dfe0deaa..2a22099aa72 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -547,10 +547,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() } } } @@ -4100,6 +4115,7 @@ class TerminalController { } } } + controlCommandCoordinator.markHandleTopologyRefreshCompleted() } // MARK: - V2 Context Resolution diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 786e0440328..a203f00a070 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -14300,10 +14300,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 @@ -14311,7 +14316,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/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift b/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift index 58a06378713..400cd5aa9be 100644 --- a/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift +++ b/cmuxTests/RestorableAgentSessionIndexCodexWeakRecordTests.swift @@ -1,6 +1,7 @@ import Foundation import SQLite3 import Testing +import CMUXAgentLaunch #if canImport(cmux_DEV) @testable import cmux_DEV diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 246edc62f42..ff9cc447ba6 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -59,6 +59,9 @@ struct SurfaceSelectionTests { 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), ] @@ -470,6 +473,9 @@ struct SurfaceSelectionTests { 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 e7a9e13788450f8c1876931bfa3d99b4035a1594 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Thu, 27 Aug 2026 17:55:26 -0700 Subject: [PATCH 38/44] Return WebKit selection evaluation results --- Sources/Panels/WebSurfaceSelectionReader.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 9b44fa57f1a..282a462367e 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -406,7 +406,7 @@ final class WebSurfaceSelectionReader { private static let evaluationTimeoutMilliseconds = 4_000 private static let script = """ - (async () => { +return await (async () => { const readSelection = () => { const runtime = globalThis.__cmuxSurfaceSelectionRuntime; if (!runtime || typeof runtime.read !== 'function') return null; @@ -439,7 +439,7 @@ final class WebSurfaceSelectionReader { Promise.resolve().then(readSelection), new Promise((resolve) => setTimeout(() => resolve(null), \(WebSurfaceSelectionReader.evaluationTimeoutMilliseconds))) ]); - })() +})() """ /// Installs the event-owned snapshot in the page world because WebKit does From 9d923ad8b6d3e74bfb2fe9e5a2c939e4bc73ea45 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Thu, 27 Aug 2026 17:59:33 -0700 Subject: [PATCH 39/44] Fix WebKit selection script indentation --- Sources/Panels/WebSurfaceSelectionReader.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 282a462367e..07ac7a6a67e 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -406,7 +406,7 @@ final class WebSurfaceSelectionReader { private static let evaluationTimeoutMilliseconds = 4_000 private static let script = """ -return await (async () => { + return await (async () => { const readSelection = () => { const runtime = globalThis.__cmuxSurfaceSelectionRuntime; if (!runtime || typeof runtime.read !== 'function') return null; @@ -439,7 +439,7 @@ return await (async () => { Promise.resolve().then(readSelection), new Promise((resolve) => setTimeout(() => resolve(null), \(WebSurfaceSelectionReader.evaluationTimeoutMilliseconds))) ]); -})() + })() """ /// Installs the event-owned snapshot in the page world because WebKit does From bc8011d5ddae6c5678584689e73837dc32999792 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Thu, 27 Aug 2026 19:57:03 -0700 Subject: [PATCH 40/44] Stabilize browser selection fixture focus transitions --- cmuxTests/SurfaceSelectionTests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index ff9cc447ba6..7fabd7da76c 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -331,6 +331,7 @@ struct SurfaceSelectionTests { 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); @@ -371,6 +372,7 @@ struct SurfaceSelectionTests { 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); @@ -464,6 +466,9 @@ struct SurfaceSelectionTests { _ = try await panel.evaluateJavaScript( """ (() => { + document.getElementById('passage').dispatchEvent( + new Event('pointerdown', { bubbles: true }) + ); window.getSelection().removeAllRanges(); document.dispatchEvent(new Event('selectionchange')); return true; From 31117448327e0b2f21f4e7c8e77a36901c3342ba Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 2 Sep 2026 05:25:01 -0700 Subject: [PATCH 41/44] Preserve browser selections across native focus handoff --- Sources/Panels/WebSurfaceSelectionReader.swift | 18 +++++++++++++----- cmuxTests/SurfaceSelectionTests.swift | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Sources/Panels/WebSurfaceSelectionReader.swift b/Sources/Panels/WebSurfaceSelectionReader.swift index 07ac7a6a67e..2fc4efb0efe 100644 --- a/Sources/Panels/WebSurfaceSelectionReader.swift +++ b/Sources/Panels/WebSurfaceSelectionReader.swift @@ -337,12 +337,20 @@ final class WebSurfaceSelectionReader { captureQueued = false; const targetWindow = targetDocument.defaultView; if (!targetWindow) return; - // A collapsed selectionchange while the document is unfocused is - // WebKit's native-focus handoff signal. When the page still owns - // focus, an empty range is an intentional page-side clear. - capture(targetWindow, targetDocument.hasFocus()); + // `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. @@ -357,7 +365,7 @@ final class WebSurfaceSelectionReader { if (keyChangesSelection(event)) clear(); }, true); targetDocument.addEventListener('focusin', captureDocument, true); - targetDocument.addEventListener('input', captureDocument, true); + targetDocument.addEventListener('input', reconcileInput, true); const invalidateForNavigation = () => clear(targetDocument); targetDocument.defaultView?.addEventListener('hashchange', invalidateForNavigation, true); targetDocument.defaultView?.addEventListener('popstate', invalidateForNavigation, true); diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 7fabd7da76c..7932e70d0c1 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -221,6 +221,24 @@ struct SurfaceSelectionTests { #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 document.hasFocus(); + })() + """ + ) + #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( """ (() => { From b04a102ba0a3d67031994ebd6df9e0589bb4898f Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 2 Sep 2026 05:52:32 -0700 Subject: [PATCH 42/44] Make focus handoff regression assertion environment independent --- cmuxTests/SurfaceSelectionTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/SurfaceSelectionTests.swift b/cmuxTests/SurfaceSelectionTests.swift index 7932e70d0c1..3e380b0d19d 100644 --- a/cmuxTests/SurfaceSelectionTests.swift +++ b/cmuxTests/SurfaceSelectionTests.swift @@ -229,7 +229,7 @@ struct SurfaceSelectionTests { (() => { window.getSelection().removeAllRanges(); document.dispatchEvent(new Event('selectionchange')); - return document.hasFocus(); + return window.getSelection().isCollapsed; })() """ ) From 728b8e211d03a99291b989a82e18dc3ed126d025 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 2 Sep 2026 06:13:46 -0700 Subject: [PATCH 43/44] Give CLI routing tests a bounded cold-start margin --- .../CLIExplicitSurfaceRoutingTests.swift | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift index 7018640cfe6..cde94a98c7a 100644 --- a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift +++ b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift @@ -80,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)) @@ -303,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)) @@ -355,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)) @@ -405,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)) @@ -469,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)) @@ -531,10 +531,10 @@ struct CLIExplicitSurfaceRoutingTests { executablePath: try Self.bundledCLIPath(), arguments: arguments, 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)) @@ -579,10 +579,10 @@ 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) @@ -627,10 +627,10 @@ struct CLIExplicitSurfaceRoutingTests { executablePath: try Self.bundledCLIPath(), arguments: arguments, 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)) return (result, try state.requestObjects()) @@ -647,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 = 15 + private static let serverTimeout: TimeInterval = 15 private static let callerSurfaceId = "22222222-2222-2222-2222-222222222222" private static let targetSurfaceRef = "surface:11" private static let numericSurfaceId = "33333333-3333-3333-3333-333333333333" From b7d7eb3b65e38ec57704f6abb1004e3dfbf4d475 Mon Sep 17 00:00:00 2001 From: austinpower1258 <austinwang115@gmail.com> Date: Wed, 2 Sep 2026 07:27:46 -0700 Subject: [PATCH 44/44] Allow more headroom for hosted CLI routing tests --- cmuxTests/CLIExplicitSurfaceRoutingTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift index cde94a98c7a..db538623fad 100644 --- a/cmuxTests/CLIExplicitSurfaceRoutingTests.swift +++ b/cmuxTests/CLIExplicitSurfaceRoutingTests.swift @@ -650,8 +650,8 @@ struct CLIExplicitSurfaceRoutingTests { // 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 = 15 - private static let serverTimeout: TimeInterval = 15 + 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"