diff --git a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift index a80c78f15074..8346ba4b00e4 100644 --- a/CLI/CMUXCLI+CodexFireAndForgetHooks.swift +++ b/CLI/CMUXCLI+CodexFireAndForgetHooks.swift @@ -119,10 +119,11 @@ extension CMUXCLI { let runner = "payload=\"$1\"; shift; \"$@\" <\"$payload\" >/dev/null 2>&1 & child=\"$!\"; ( sleep 30; kill \"$child\" 2>/dev/null || true ) & watchdog=\"$!\"; wait \"$child\" 2>/dev/null || true; kill \"$watchdog\" 2>/dev/null || true; rm -f \"$payload\"" let noOp = stdinDrainingHookNoOpShellCommand return [ - "cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"", + "cmux_cli=\"${CMUX_CODEX_HOOK_CMUX_BIN:-}\"", + "if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"${CMUX_BUNDLED_CLI_PATH:-}\"; fi", "if [ -z \"$cmux_cli\" ] || [ ! -x \"$cmux_cli\" ]; then cmux_cli=\"$(command -v cmux 2>/dev/null || true)\"; fi", "agent_pid=\"${CMUX_CODEX_PID:-${PPID:-}}\"", - "if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then payload=\"$(mktemp \"${TMPDIR:-/tmp}/cmux-codex-hook.XXXXXX\" 2>/dev/null || mktemp -t cmux-codex-hook 2>/dev/null)\" || { \(noOp); exit 0; }; cat >\"$payload\" || true; if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" --socket \"$CMUX_SOCKET_PATH\" \(routedArguments) >/dev/null 2>&1 & else CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$cmux_cli\" \(routedArguments) >/dev/null 2>&1 & fi; echo '{}'; else \(noOp); fi", + "if [ -n \"$CMUX_SURFACE_ID\" ] && [ \"$\(def.disableEnvVar)\" != \"1\" ] && [ -n \"$cmux_cli\" ]; then payload=\"$(mktemp \"${TMPDIR:-/tmp}/cmux-codex-hook.XXXXXX\" 2>/dev/null || mktemp -t cmux-codex-hook 2>/dev/null)\" || { \(noOp); exit 0; }; cat >\"$payload\" || true; set -- \"$cmux_cli\"; if [ -n \"${CMUX_SOCKET_PATH:-}\" ]; then set -- \"$@\" --socket \"$CMUX_SOCKET_PATH\"; fi; set -- \"$@\" \(routedArguments); if [ -n \"${CMUX_WORKSPACE_ID:-}\" ]; then set -- \"$@\" --workspace \"$CMUX_WORKSPACE_ID\" --surface \"$CMUX_SURFACE_ID\"; fi; CMUX_CODEX_PID=\"$agent_pid\" nohup sh -c '\(runner)' cmux-codex-hook \"$payload\" \"$@\" >/dev/null 2>&1 & echo '{}'; else \(noOp); fi", ].joined(separator: "; ") } } diff --git a/CLI/CMUXCLI+TerminalOverlay.swift b/CLI/CMUXCLI+TerminalOverlay.swift new file mode 100644 index 000000000000..7fec63df6475 --- /dev/null +++ b/CLI/CMUXCLI+TerminalOverlay.swift @@ -0,0 +1,251 @@ +import Foundation + +extension CMUXCLI { + static let surfaceOverlayCommandUsageLine = String( + localized: "cli.surfaceOverlay.usageLine", + defaultValue: "surface overlay [--workspace ] [--surface ] [--window ]" + ) + + static let surfaceOverlayCommandHelp = String( + localized: "cli.surfaceOverlay.help", + defaultValue: """ + Usage: cmux surface overlay set [--anchor ] [--position ] [target flags] + cmux surface overlay list [target flags] + cmux surface overlay remove [target flags] + cmux surface overlay clear [target flags] + + Render passive one-row strips over a terminal without taking keyboard or mouse input. + Viewport stays at the visible top. Scrollback stays at the captured top row. + Sticky follows the captured row, then pins when that row reaches the viewport top. + + Target flags: + --workspace Workspace context (default: $CMUX_WORKSPACE_ID) + --surface Terminal context (default: $CMUX_SURFACE_ID) + --window Window context for workspace and surface refs/indexes + + Set flags: + --anchor Vertical anchor (default: viewport) + --position Text alignment (default: center) + + Use '-' as text to read the overlay from standard input. + + Examples: + cmux surface overlay set latest-message "check the auth error" + printf 'build\\npassed' | cmux surface overlay set build-status - --position right + cmux surface overlay set review-note "inspect this output" --anchor scrollback --position left + cmux surface overlay set latest-message "keep this visible" --anchor sticky --position left + """ + ) + + func runSurfaceOverlayCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool, + idFormat: CLIIDFormat, + windowOverride: String? + ) throws { + guard let subcommand = commandArgs.first?.lowercased() else { + throw CLIError(message: String( + localized: "cli.surfaceOverlay.error.missingSubcommand", + defaultValue: "surface overlay requires set, list, remove, or clear" + )) + } + let rest = Array(commandArgs.dropFirst()) + let target = try surfaceCommandTarget(rest, client: client, windowOverride: windowOverride) + var params = target.params + + switch subcommand { + case "set": + let (anchor, remainingAfterAnchor) = parseOption(target.remaining, name: "--anchor") + let (position, remainingAfterPosition) = parseOption( + remainingAfterAnchor, + name: "--position" + ) + let split = overlayArguments(splitAtTerminator: remainingAfterPosition) + guard let id = split.before.first else { + throw CLIError(message: String( + localized: "cli.surfaceOverlay.error.setRequiresID", + defaultValue: "surface overlay set requires an id" + )) + } + if let unknown = split.before.dropFirst().first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.surfaceOverlay.error.unknownFlagFormat", + defaultValue: "surface overlay: unknown flag '%@'" + ), + unknown + )) + } + let textTokens = Array(split.before.dropFirst()) + split.after + guard !textTokens.isEmpty else { + throw CLIError(message: String( + localized: "cli.surfaceOverlay.error.setRequiresText", + defaultValue: "surface overlay set requires text or '-' for standard input" + )) + } + let text: String + if textTokens == ["-"] { + text = String( + data: FileHandle.standardInput.readDataToEndOfFile(), + encoding: .utf8 + ) ?? "" + } else { + text = textTokens.joined(separator: " ") + } + params["overlay_id"] = id + params["text"] = text + if let anchor { params["anchor"] = anchor } + if let position { params["position"] = position } + let payload = try client.sendV2(method: "surface.overlay.set", params: params) + printSurfaceOverlayPayload( + payload, + jsonOutput: jsonOutput, + idFormat: idFormat, + fallback: (payload["overlay"] as? [String: Any])?["id"] as? String ?? id + ) + + case "list": + try requireNoSurfaceOverlayArguments(target.remaining, subcommand: subcommand) + let payload = try client.sendV2(method: "surface.overlay.list", params: params) + if jsonOutput { + print(jsonString(formatIDs(payload, mode: idFormat))) + } else { + for overlay in payload["overlays"] as? [[String: Any]] ?? [] { + let id = overlay["id"] as? String ?? "" + let anchor = overlay["anchor"] as? String ?? "" + let position = overlay["position"] as? String ?? "" + let text = (overlay["text"] as? String ?? "") + .replacingOccurrences(of: "\n", with: "\\n") + print("\(id)\t\(anchor)\t\(position)\t\(text)") + } + } + + case "remove": + let split = overlayArguments(splitAtTerminator: target.remaining) + let arguments = split.before + split.after + guard let id = arguments.first else { + throw CLIError(message: String( + localized: "cli.surfaceOverlay.error.removeRequiresID", + defaultValue: "surface overlay remove requires an id" + )) + } + guard arguments.count == 1 else { + throw CLIError(message: String( + localized: "cli.surfaceOverlay.error.removeExtraArguments", + defaultValue: "surface overlay remove accepts one id" + )) + } + params["overlay_id"] = id + let payload = try client.sendV2(method: "surface.overlay.remove", params: params) + printSurfaceOverlayPayload( + payload, + jsonOutput: jsonOutput, + idFormat: idFormat, + fallback: (payload["removed"] as? Bool) == true ? "true" : "false" + ) + + case "clear": + try requireNoSurfaceOverlayArguments(target.remaining, subcommand: subcommand) + let payload = try client.sendV2(method: "surface.overlay.clear", params: params) + let removed = payload["removed"] as? Int ?? 0 + printSurfaceOverlayPayload( + payload, + jsonOutput: jsonOutput, + idFormat: idFormat, + fallback: String(removed) + ) + + default: + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.surfaceOverlay.error.unsupportedSubcommandFormat", + defaultValue: "Unsupported surface overlay subcommand: %@" + ), + subcommand + )) + } + } + + func publishLatestCodexUserMessageOverlay( + _ prompt: String, + workspaceId: String, + surfaceId: String, + client: SocketClient + ) throws { + let boundedPrompt = terminalOverlayText( + prompt, + maximumUTF8Bytes: 16_384 + ) + guard !boundedPrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return + } + _ = try client.sendV2( + method: "surface.overlay.set", + params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "overlay_id": "agent.codex.latest-user-message", + "text": boundedPrompt, + "anchor": "viewport", + "position": "left", + ] + ) + } + + private func terminalOverlayText(_ text: String, maximumUTF8Bytes: Int) -> String { + guard text.utf8.count > maximumUTF8Bytes else { return text } + var result = "" + result.reserveCapacity(maximumUTF8Bytes) + var remainingBytes = maximumUTF8Bytes + for character in text { + let value = String(character) + let byteCount = value.utf8.count + guard byteCount <= remainingBytes else { break } + result.append(character) + remainingBytes -= byteCount + } + return result + } + + private func overlayArguments( + splitAtTerminator args: [String] + ) -> (before: [String], after: [String]) { + guard let index = args.firstIndex(of: "--") else { + return (args, []) + } + return ( + Array(args[.. SurfaceResumeTarget { + ) throws -> SurfaceCommandTarget { let splitArgs = splitAtArgumentTerminator(args) let (workspaceOpt, rem1) = parseOption(splitArgs.options, name: "--workspace") let (surfaceOpt, rem2) = parseOption(rem1, name: "--surface") @@ -7236,7 +7244,7 @@ struct CMUXCLI { ) if let surfaceId { params["surface_id"] = surfaceId } let remainingWithArgv = remaining + (splitArgs.argv.map { ["--"] + $0 } ?? []) - return SurfaceResumeTarget(params: params, remaining: remainingWithArgv) + return SurfaceCommandTarget(params: params, remaining: remainingWithArgv) } private func cliShellQuote(_ value: String) -> String { @@ -16783,6 +16791,8 @@ struct CMUXCLI { """ case "surface", "surface-resume": return """ + \(Self.surfaceOverlayCommandHelp) + Usage: cmux surface resume set [flags] -- cmux surface resume set [flags] --shell cmux surface resume show [--json] [flags] @@ -31579,6 +31589,24 @@ export default CMUXSessionRestore; nestedPromptEvent: nestedPromptSubmit, env: env ) + // The prompt strip describes accepted user input, not the duration + // of the agent turn. Publish it before lifecycle/socket work so a + // fast Stop cannot race the strip out of existence. + if !suppressVisibleMutations, + !incomingCodexTurnIsTerminal, + def.name == "codex", + let prompt = feedPromptText(from: input.rawObject ?? input.object) { + do { + try publishLatestCodexUserMessageOverlay( + prompt, + workspaceId: workspaceId, + surfaceId: surfaceId, + client: client + ) + } catch { + telemetry.breadcrumb("codex-hook.prompt-submit.overlay-failed") + } + } if !suppressVisibleMutations && !incomingCodexTurnIsTerminal { if codexPromptTurnWentTerminal() { stopStaleCodexPromptSubmit() @@ -32675,7 +32703,7 @@ export default CMUXSessionRestore; } } - private func feedPromptText(from object: [String: Any]?) -> String? { + func feedPromptText(from object: [String: Any]?) -> String? { guard let object else { return nil } if let direct = firstString(in: object, keys: ["prompt", "text", "message", "body"]) { return direct @@ -36076,6 +36104,7 @@ export default CMUXSessionRestore; split-off --surface [--workspace ] [--window ] [--focus ] reorder-surface --surface (--index | --before | --after ) [--workspace ] [--window ] [--focus ] tab-action --action [--tab ] [--surface ] [--workspace ] [--window ] [--title ] [--url ] [--focus ] + \(Self.surfaceOverlayCommandUsageLine) surface resume [--workspace ] [--surface ] [--window ] rename-tab [--workspace ] [--tab ] [--surface ] [--window ] drag-surface-to-split --surface <id|ref|index> <left|right|up|down> [--workspace <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift index 22f5ef074f5f..75417df1d8da 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift @@ -69,6 +69,14 @@ extension ControlCommandCoordinator { // dispatched app-side, not through this @MainActor coordinator. case "surface.trigger_flash": return surfaceTriggerFlash(request.params) + case "surface.overlay.list": + return surfaceOverlay(request.params, action: .list) + case "surface.overlay.set": + return surfaceOverlaySet(request.params) + case "surface.overlay.remove": + return surfaceOverlayRemove(request.params) + case "surface.overlay.clear": + return surfaceOverlay(request.params, action: .clear) case "debug.terminals": return debugTerminals(request.params) default: diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+SurfaceOverlay.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+SurfaceOverlay.swift new file mode 100644 index 000000000000..7419a1237a81 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+SurfaceOverlay.swift @@ -0,0 +1,188 @@ +internal import Foundation + +extension ControlCommandCoordinator { + func surfaceOverlaySet(_ params: [String: JSONValue]) -> ControlCallResult { + let id = rawString(params, "overlay_id") ?? "" + let text = rawString(params, "text") ?? "" + let rawAnchor = optionalTrimmedRawString(params, "anchor") ?? "viewport" + let anchor: ControlSurfaceOverlayAnchor + switch rawAnchor.lowercased() { + case "viewport", "viewport-top": + anchor = .viewportTop + case "scrollback", "scrollback-top": + anchor = .scrollbackTop + case "sticky", "scrollback-sticky": + anchor = .scrollbackSticky + default: + return overlayValidationError(.invalidAnchor(rawAnchor)) + } + + let rawAlignment = optionalTrimmedRawString(params, "position") ?? "center" + guard let alignment = ControlSurfaceOverlayAlignment(rawValue: rawAlignment.lowercased()) else { + return overlayValidationError(.invalidAlignment(rawAlignment)) + } + return surfaceOverlay( + params, + action: .set(ControlSurfaceOverlaySetInputs( + id: id, + text: text, + anchor: anchor, + alignment: alignment + )) + ) + } + + func surfaceOverlayRemove(_ params: [String: JSONValue]) -> ControlCallResult { + surfaceOverlay( + params, + action: .remove(id: rawString(params, "overlay_id") ?? "") + ) + } + + func surfaceOverlay( + _ params: [String: JSONValue], + action: ControlSurfaceOverlayAction + ) -> ControlCallResult { + let routing = routingSelectors(params) + let resolution = context?.controlSurfaceOverlay( + routing: routing, + surfaceID: uuid(params, "surface_id"), + hasSurfaceIDParam: params["surface_id"] != nil, + action: action + ) ?? .tabManagerUnavailable + return surfaceOverlayResult(resolution) + } + + private func surfaceOverlayResult( + _ resolution: ControlSurfaceOverlayResolution + ) -> ControlCallResult { + let strings = overlayStrings() + switch resolution { + case .tabManagerUnavailable: + return .err(code: "unavailable", message: strings.tabManagerUnavailable, data: nil) + case .workspaceNotFound: + return .err(code: "not_found", message: strings.workspaceNotFound, data: nil) + case .surfaceNotFound: + return .err(code: "not_found", message: strings.surfaceNotFound, data: nil) + case .noFocusedSurface: + return .err(code: "not_found", message: strings.noFocusedSurface, data: nil) + case .surfaceNotTerminal(let surfaceID): + return .err( + code: "invalid_params", + message: strings.surfaceNotTerminal, + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .validationFailed(let error): + return overlayValidationError(error) + case .scrollbackUnavailable(let surfaceID): + return .err( + code: "unavailable", + message: strings.scrollbackUnavailable, + data: .object(["surface_id": .string(surfaceID.uuidString)]) + ) + case .listed(let windowID, let workspaceID, let surfaceID, let overlays): + var payload = surfaceOverlayTargetPayload( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID + ) + payload["overlays"] = .array(overlays.map(surfaceOverlayPayload)) + return .ok(.object(payload)) + case .set(let windowID, let workspaceID, let surfaceID, let overlay): + var payload = surfaceOverlayTargetPayload( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID + ) + payload["overlay"] = surfaceOverlayPayload(overlay) + return .ok(.object(payload)) + case .removed(let windowID, let workspaceID, let surfaceID, let overlayID, let removed): + var payload = surfaceOverlayTargetPayload( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID + ) + payload["overlay_id"] = .string(overlayID) + payload["removed"] = .bool(removed) + return .ok(.object(payload)) + case .cleared(let windowID, let workspaceID, let surfaceID, let removedCount): + var payload = surfaceOverlayTargetPayload( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID + ) + payload["removed"] = .int(Int64(removedCount)) + return .ok(.object(payload)) + } + } + + private func overlayValidationError( + _ error: ControlSurfaceOverlayValidationError + ) -> ControlCallResult { + let strings = overlayStrings() + let message: String + switch error { + case .invalidIdentifier: + message = strings.invalidIdentifier + case .emptyText: + message = strings.emptyText + case .textTooLong(let maxUTF8Bytes): + message = String.localizedStringWithFormat( + strings.textTooLongFormat, + Int64(maxUTF8Bytes) + ) + case .invalidAnchor(let value): + message = String.localizedStringWithFormat(strings.invalidAnchorFormat, value) + case .invalidAlignment(let value): + message = String.localizedStringWithFormat(strings.invalidAlignmentFormat, value) + } + return .err(code: "invalid_params", message: message, data: nil) + } + + private func overlayStrings() -> ControlSurfaceOverlayStrings { + context?.controlSurfaceOverlayStrings() ?? ControlSurfaceOverlayStrings( + tabManagerUnavailable: "", + workspaceNotFound: "", + surfaceNotFound: "", + noFocusedSurface: "", + surfaceNotTerminal: "", + invalidIdentifier: "", + emptyText: "", + textTooLongFormat: "", + invalidAnchorFormat: "", + invalidAlignmentFormat: "", + scrollbackUnavailable: "" + ) + } + + private func surfaceOverlayTargetPayload( + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID + ) -> [String: JSONValue] { + [ + "window_id": orNull(windowID?.uuidString), + "window_ref": ref(.window, windowID), + "workspace_id": .string(workspaceID.uuidString), + "workspace_ref": ref(.workspace, workspaceID), + "surface_id": .string(surfaceID.uuidString), + "surface_ref": ref(.surface, surfaceID), + ] + } + + private func surfaceOverlayPayload(_ overlay: ControlSurfaceOverlaySnapshot) -> JSONValue { + let anchor = switch overlay.anchor { + case .viewportTop: "viewport" + case .scrollbackTop: "scrollback" + case .scrollbackSticky: "sticky" + } + return .object([ + "id": .string(overlay.id), + "text": .string(overlay.text), + "anchor": .string(anchor), + "position": .string(overlay.alignment.rawValue), + "scrollback_row": overlay.scrollbackRow.map { .int(Int64($0)) } ?? .null, + "row_space_revision": overlay.rowSpaceRevision.map { .int(Int64(clamping: $0)) } ?? .null, + ]) + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift index d81405ca74df..152cc50bda39 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceContext.swift @@ -187,6 +187,21 @@ public protocol ControlSurfaceContext: AnyObject { surfaceID: UUID? ) -> ControlSurfaceTriggerFlashResolution + // MARK: - overlay + + /// The app-bundle-resolved messages for `surface.overlay.*` validation. + func controlSurfaceOverlayStrings() -> ControlSurfaceOverlayStrings + + /// Reads or mutates the retained overlays for one terminal surface. + /// + /// This path never focuses a window, workspace, pane, or surface. + func controlSurfaceOverlay( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + action: ControlSurfaceOverlayAction + ) -> ControlSurfaceOverlayResolution + /// The app-bundle-resolved localized terminal-input error strings, shared by /// `surface.send_text` and `surface.send_key`. The app resolves each /// `String(localized:)` so the package never binds them to the wrong bundle. diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceOverlay.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceOverlay.swift new file mode 100644 index 000000000000..ed956b78c250 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlSurfaceOverlay.swift @@ -0,0 +1,155 @@ +public import Foundation + +/// Producer-facing vertical placement accepted by `surface.overlay.set`. +public enum ControlSurfaceOverlayAnchor: String, Equatable, Sendable { + case viewportTop + case scrollbackTop + case scrollbackSticky +} + +/// Producer-facing horizontal placement accepted by `surface.overlay.set`. +public enum ControlSurfaceOverlayAlignment: String, Equatable, Sendable { + case left + case center + case right +} + +/// Parsed input for one overlay upsert. +public struct ControlSurfaceOverlaySetInputs: Equatable, Sendable { + public let id: String + public let text: String + public let anchor: ControlSurfaceOverlayAnchor + public let alignment: ControlSurfaceOverlayAlignment + + public init( + id: String, + text: String, + anchor: ControlSurfaceOverlayAnchor, + alignment: ControlSurfaceOverlayAlignment + ) { + self.id = id + self.text = text + self.anchor = anchor + self.alignment = alignment + } +} + +/// One shared mutation/read path for all overlay entrypoints. +public enum ControlSurfaceOverlayAction: Equatable, Sendable { + case list + case set(ControlSurfaceOverlaySetInputs) + case remove(id: String) + case clear +} + +/// A socket-safe snapshot of one resolved terminal overlay. +public struct ControlSurfaceOverlaySnapshot: Equatable, Sendable { + public let id: String + public let text: String + public let anchor: ControlSurfaceOverlayAnchor + public let alignment: ControlSurfaceOverlayAlignment + public let scrollbackRow: Int? + public let rowSpaceRevision: UInt64? + + public init( + id: String, + text: String, + anchor: ControlSurfaceOverlayAnchor, + alignment: ControlSurfaceOverlayAlignment, + scrollbackRow: Int? = nil, + rowSpaceRevision: UInt64? = nil + ) { + self.id = id + self.text = text + self.anchor = anchor + self.alignment = alignment + self.scrollbackRow = scrollbackRow + self.rowSpaceRevision = rowSpaceRevision + } +} + +/// Validation discriminators returned by the app after core model validation. +public enum ControlSurfaceOverlayValidationError: Equatable, Sendable { + case invalidIdentifier + case emptyText + case textTooLong(maxUTF8Bytes: Int) + case invalidAnchor(String) + case invalidAlignment(String) +} + +/// Localized messages supplied by the app bundle for overlay command failures. +public struct ControlSurfaceOverlayStrings: Equatable, Sendable { + public let tabManagerUnavailable: String + public let workspaceNotFound: String + public let surfaceNotFound: String + public let noFocusedSurface: String + public let surfaceNotTerminal: String + public let invalidIdentifier: String + public let emptyText: String + public let textTooLongFormat: String + public let invalidAnchorFormat: String + public let invalidAlignmentFormat: String + public let scrollbackUnavailable: String + + public init( + tabManagerUnavailable: String, + workspaceNotFound: String, + surfaceNotFound: String, + noFocusedSurface: String, + surfaceNotTerminal: String, + invalidIdentifier: String, + emptyText: String, + textTooLongFormat: String, + invalidAnchorFormat: String, + invalidAlignmentFormat: String, + scrollbackUnavailable: String + ) { + self.tabManagerUnavailable = tabManagerUnavailable + self.workspaceNotFound = workspaceNotFound + self.surfaceNotFound = surfaceNotFound + self.noFocusedSurface = noFocusedSurface + self.surfaceNotTerminal = surfaceNotTerminal + self.invalidIdentifier = invalidIdentifier + self.emptyText = emptyText + self.textTooLongFormat = textTooLongFormat + self.invalidAnchorFormat = invalidAnchorFormat + self.invalidAlignmentFormat = invalidAlignmentFormat + self.scrollbackUnavailable = scrollbackUnavailable + } +} + +/// The resolved target and action result for `surface.overlay.*`. +public enum ControlSurfaceOverlayResolution: Equatable, Sendable { + case tabManagerUnavailable + case workspaceNotFound + case surfaceNotFound + case noFocusedSurface + case surfaceNotTerminal(UUID) + case validationFailed(ControlSurfaceOverlayValidationError) + case scrollbackUnavailable(UUID) + case listed( + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID, + overlays: [ControlSurfaceOverlaySnapshot] + ) + case set( + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID, + overlay: ControlSurfaceOverlaySnapshot + ) + case removed( + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID, + overlayID: String, + removed: Bool + ) + case cleared( + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID, + removedCount: Int + ) +} diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift index a44e98f8bcfe..a022b57ea023 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift @@ -468,6 +468,29 @@ extension ControlSurfaceContext { surfaceID: UUID? ) -> ControlSurfaceTriggerFlashResolution { .tabManagerUnavailable } + func controlSurfaceOverlayStrings() -> ControlSurfaceOverlayStrings { + ControlSurfaceOverlayStrings( + tabManagerUnavailable: "window unavailable", + workspaceNotFound: "workspace not found", + surfaceNotFound: "surface not found", + noFocusedSurface: "no focused surface", + surfaceNotTerminal: "surface not terminal", + invalidIdentifier: "invalid identifier", + emptyText: "empty text", + textTooLongFormat: "text too long: %lld", + invalidAnchorFormat: "invalid anchor: %@", + invalidAlignmentFormat: "invalid position: %@", + scrollbackUnavailable: "scrollback unavailable" + ) + } + + func controlSurfaceOverlay( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + action: ControlSurfaceOverlayAction + ) -> ControlSurfaceOverlayResolution { .tabManagerUnavailable } + nonisolated func controlSurfaceInputStrings() -> ControlSurfaceInputStrings { ControlSurfaceInputStrings(inputQueueFull: "", surfaceUnavailable: "", processExited: "") } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift index 1496e5d4b79d..c5d143fcc32d 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorSurfaceTests.swift @@ -635,4 +635,118 @@ struct ControlCommandCoordinatorSurfaceTests { #expect(payload["branch"] == .null) #expect(payload["cleared"] == .bool(true)) } + + @Test func surfaceOverlaySetParsesPlacementAndReturnsResolvedOverlay() throws { + let context = FakeSurfaceControlCommandContext() + let windowID = UUID() + let workspaceID = UUID() + let surfaceID = UUID() + context.overlayResolution = .set( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID, + overlay: ControlSurfaceOverlaySnapshot( + id: "agent.latest-message", + text: "keep this visible", + anchor: .scrollbackSticky, + alignment: .right, + scrollbackRow: 42, + rowSpaceRevision: 7 + ) + ) + let coordinator = ControlCommandCoordinator(context: context) + + let result = coordinator.handle(ControlRequest( + id: .int(1), + method: "surface.overlay.set", + params: [ + "workspace_id": .string(workspaceID.uuidString), + "surface_id": .string(surfaceID.uuidString), + "overlay_id": .string("agent.latest-message"), + "text": .string("keep this visible"), + "anchor": .string("sticky"), + "position": .string("right"), + ] + )) + + #expect(context.overlayInvocation?.surfaceID == surfaceID) + #expect(context.overlayInvocation?.hasSurfaceIDParam == true) + #expect(context.overlayInvocation?.action == .set(ControlSurfaceOverlaySetInputs( + id: "agent.latest-message", + text: "keep this visible", + anchor: .scrollbackSticky, + alignment: .right + ))) + guard case .ok(.object(let payload)) = result, + case .object(let overlay)? = payload["overlay"] else { + Issue.record("expected overlay set payload") + return + } + #expect(payload["window_id"] == .string(windowID.uuidString)) + #expect(payload["workspace_id"] == .string(workspaceID.uuidString)) + #expect(payload["surface_id"] == .string(surfaceID.uuidString)) + #expect(overlay["id"] == .string("agent.latest-message")) + #expect(overlay["anchor"] == .string("sticky")) + #expect(overlay["position"] == .string("right")) + #expect(overlay["scrollback_row"] == .int(42)) + #expect(overlay["row_space_revision"] == .int(7)) + } + + @Test func surfaceOverlayListExposesResolvedScrollbackAnchor() { + let context = FakeSurfaceControlCommandContext() + let workspaceID = UUID() + let surfaceID = UUID() + context.overlayResolution = .listed( + windowID: nil, + workspaceID: workspaceID, + surfaceID: surfaceID, + overlays: [ControlSurfaceOverlaySnapshot( + id: "build-note", + text: "anchored", + anchor: .scrollbackTop, + alignment: .left, + scrollbackRow: 42, + rowSpaceRevision: 7 + )] + ) + let coordinator = ControlCommandCoordinator(context: context) + + let result = coordinator.handle(ControlRequest( + id: .int(1), + method: "surface.overlay.list", + params: [:] + )) + + guard case .ok(.object(let payload)) = result, + case .array(let overlays)? = payload["overlays"], + case .object(let overlay)? = overlays.first else { + Issue.record("expected overlay list payload") + return + } + #expect(overlay["anchor"] == .string("scrollback")) + #expect(overlay["scrollback_row"] == .int(42)) + #expect(overlay["row_space_revision"] == .int(7)) + } + + @Test func surfaceOverlayRejectsUnknownAnchorBeforeMutation() { + let context = FakeSurfaceControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + + let result = coordinator.handle(ControlRequest( + id: .int(1), + method: "surface.overlay.set", + params: [ + "overlay_id": .string("note"), + "text": .string("content"), + "anchor": .string("cursor"), + ] + )) + + #expect(result == .err( + code: "invalid_params", + message: "invalid anchor: cursor", + data: nil + )) + #expect(context.overlayInvocation == nil) + } } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift index 144e15d716f0..a51e226d3592 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/FakeSurfaceControlCommandContext.swift @@ -18,6 +18,13 @@ final class FakeSurfaceControlCommandContext: ControlCommandContext { var reportGitResolution: ControlSurfaceReportGitBranchResolution = .recorded(surfaceID: UUID()) var reportedGit: (workspaceID: UUID, requestedSurfaceID: UUID?, branch: String, isDirty: Bool?)? var clearedGit: (workspaceID: UUID, requestedSurfaceID: UUID?)? + var overlayResolution: ControlSurfaceOverlayResolution = .tabManagerUnavailable + var overlayInvocation: ( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + action: ControlSurfaceOverlayAction + )? func controlWindowSummaries() -> [ControlWindowSummary] { [] } func controlResolveCurrentWindow(routing: ControlRoutingSelectors) -> ControlCurrentWindowResolution { @@ -110,4 +117,14 @@ final class FakeSurfaceControlCommandContext: ControlCommandContext { clearedGit = (workspaceID, requestedSurfaceID) return reportGitResolution } + + func controlSurfaceOverlay( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + action: ControlSurfaceOverlayAction + ) -> ControlSurfaceOverlayResolution { + overlayInvocation = (routing, surfaceID, hasSurfaceIDParam, action) + return overlayResolution + } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift index c0aa51bac652..b9f208650757 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Hosting/TerminalSurfacePaneHosting.swift @@ -1,4 +1,5 @@ public import AppKit +public import CmuxTerminalCore /// The pane container view that hosts a ``TerminalSurface``'s native view /// (scrollbar, overlays, focus bookkeeping). @@ -36,4 +37,15 @@ public protocol TerminalSurfacePaneHosting: NSView { /// - drawRight: Whether the cap shrinks the width (right border). /// - drawBottom: Whether the cap shrinks the height (bottom border). func setMobileViewportBorder(size: CGSize?, drawRight: Bool, drawBottom: Bool) + + /// Captures the first visible Ghostty row in the current absolute row space. + /// + /// Returns `nil` until the runtime has established authoritative scrollback + /// geometry. Viewport-sticky overlays do not require this capture. + func captureTerminalOverlayScrollbackAnchor( + sticksToViewportTop: Bool + ) -> TerminalOverlayAnchor? + + /// Applies the complete retained overlay snapshot for this surface. + func setTerminalOverlays(_ overlays: [TerminalOverlay]) } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Overlay.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Overlay.swift new file mode 100644 index 000000000000..0d7a355d8c8b --- /dev/null +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Overlay.swift @@ -0,0 +1,79 @@ +public import CmuxTerminalCore + +/// Runtime failures that can occur after producer input has been validated. +public enum TerminalOverlayMutationError: Error, Equatable, Sendable { + case scrollbackGeometryUnavailable +} + +extension TerminalSurface { + /// Creates or replaces a keyed overlay through the surface's single retained store. + @MainActor + public func upsertTerminalOverlay( + _ request: TerminalOverlayRequest + ) -> Result<TerminalOverlay, TerminalOverlayMutationError> { + let resolvedAnchor: TerminalOverlayAnchor + switch request.anchor { + case .viewportTop: + resolvedAnchor = .viewportTop + case .scrollbackTop, .scrollbackSticky: + guard let captured = paneHost.captureTerminalOverlayScrollbackAnchor( + sticksToViewportTop: request.anchor == .scrollbackSticky + ) else { + return .failure(.scrollbackGeometryUnavailable) + } + resolvedAnchor = captured + } + + let overlay = request.resolved(anchor: resolvedAnchor) + terminalOverlayStore.upsert(overlay) + paneHost.setTerminalOverlays(terminalOverlayStore.overlays) + return .success(overlay) + } + + /// Returns the current ordered overlay snapshot. + @MainActor + public func terminalOverlays() -> [TerminalOverlay] { + terminalOverlayStore.overlays + } + + /// Removes one keyed overlay without changing focus or terminal input state. + @MainActor + @discardableResult + public func removeTerminalOverlay(id: String) -> Bool { + let removed = terminalOverlayStore.remove(id: id) + if removed { + paneHost.setTerminalOverlays(terminalOverlayStore.overlays) + } + return removed + } + + /// Removes every overlay owned by this surface. + @MainActor + @discardableResult + public func removeAllTerminalOverlays() -> Int { + let count = terminalOverlayStore.removeAll() + if count > 0 { + paneHost.setTerminalOverlays([]) + } + return count + } + + /// Drops absolute-row overlays after Ghostty invalidates their row space. + /// + /// Reflow and bounded-scrollback eviction can renumber rows without a + /// persistent row identity. Removing the stale resource is safer than + /// rendering it over unrelated output. + @MainActor + @discardableResult + public func removeInvalidatedTerminalOverlayAnchors( + currentRowSpaceRevision: UInt64 + ) -> [String] { + let removedIDs = terminalOverlayStore.removeInvalidatedScrollbackAnchors( + currentRowSpaceRevision: currentRowSpaceRevision + ) + if !removedIDs.isEmpty { + paneHost.setTerminalOverlays(terminalOverlayStore.overlays) + } + return removedIDs + } +} diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift index 5456f35f8673..bb27d4ecd4ff 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift @@ -238,6 +238,9 @@ public final class TerminalSurface: Identifiable, ObservableObject { /// The pane container view hosting this surface (concrete view injected /// through ``TerminalSurfaceViewProviding``). public let paneHost: any TerminalSurfacePaneHosting + /// Surface-scoped overlay resources. Mutated only through the main-actor + /// methods in `TerminalSurface+Overlay.swift`. + var terminalOverlayStore = TerminalOverlayStore() let surfaceView: any TerminalSurfaceNativeViewing var lastPixelWidth: UInt32 = 0 var lastPixelHeight: UInt32 = 0 diff --git a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift index c357104def55..33719907a30e 100644 --- a/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift +++ b/Packages/macOS/CmuxTerminal/Tests/CmuxTerminalTests/FakeTerminalSurfacePaneHost.swift @@ -1,5 +1,6 @@ import AppKit @testable import CmuxTerminal +import CmuxTerminalCore @MainActor final class FakeTerminalSurfacePaneHost: NSView, TerminalSurfacePaneHosting { @@ -7,6 +8,8 @@ final class FakeTerminalSurfacePaneHost: NSView, TerminalSurfacePaneHosting { private let attachesThroughSurfaceModel: Bool private let onAttach: (() -> Void)? private(set) var explicitInputCount = 0 + private(set) var terminalOverlays: [TerminalOverlay] = [] + var capturedTerminalOverlayAnchor: TerminalOverlayAnchor? init( surfaceView: FakeTerminalSurfaceNativeView, @@ -39,6 +42,22 @@ final class FakeTerminalSurfacePaneHost: NSView, TerminalSurfacePaneHosting { func syncKeyStateIndicator(text: String?) {} func setMobileViewportBorder(size: CGSize?, drawRight: Bool, drawBottom: Bool) {} + func captureTerminalOverlayScrollbackAnchor( + sticksToViewportTop: Bool + ) -> TerminalOverlayAnchor? { + guard let anchor = capturedTerminalOverlayAnchor else { return nil } + guard case .scrollback(let row, let revision, _) = anchor else { return anchor } + return .scrollback( + row: row, + rowSpaceRevision: revision, + sticksToViewportTop: sticksToViewportTop + ) + } + + func setTerminalOverlays(_ overlays: [TerminalOverlay]) { + terminalOverlays = overlays + } + func terminalSurfaceDidReceiveExplicitInput() { explicitInputCount += 1 } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Overlay/TerminalOverlay.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Overlay/TerminalOverlay.swift new file mode 100644 index 000000000000..b2b6c2880929 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Overlay/TerminalOverlay.swift @@ -0,0 +1,348 @@ +public import Foundation + +/// The horizontal edge a terminal overlay follows inside its terminal. +public enum TerminalOverlayHorizontalAlignment: String, CaseIterable, Hashable, Sendable { + case left + case center + case right +} + +/// A producer-facing anchor request. +/// +/// `scrollbackTop` captures the first visible terminal row when the overlay is +/// created. The resolved overlay then stays attached to that row rather than +/// following the viewport. `scrollbackSticky` captures the same row, follows +/// it while visible, and pins to the viewport top when that row reaches it. +public enum TerminalOverlayRequestedAnchor: String, CaseIterable, Hashable, Sendable { + case viewportTop + case scrollbackTop + case scrollbackSticky +} + +/// The resolved vertical anchor of one terminal overlay. +public enum TerminalOverlayAnchor: Equatable, Hashable, Sendable { + /// Follows the top edge of the visible terminal viewport. + case viewportTop + + /// Follows one absolute Ghostty row while its row-space revision remains + /// valid. Sticky rows pin when they reach the viewport top. + case scrollback(row: Int, rowSpaceRevision: UInt64, sticksToViewportTop: Bool) +} + +/// Validation failures for producer-supplied terminal overlay content. +public enum TerminalOverlayValidationError: Error, Equatable, Sendable { + case invalidIdentifier + case emptyText + case textTooLong(maxUTF8Bytes: Int) +} + +/// A validated request to create or replace a keyed terminal overlay. +public struct TerminalOverlayRequest: Equatable, Sendable { + public static let maximumIdentifierUTF8Bytes = 128 + public static let maximumTextUTF8Bytes = 16_384 + public static let defaultMaximumWidthColumns = 72 + public static let defaultMaximumHeightRows = 8 + + public let id: String + public let text: String + public let anchor: TerminalOverlayRequestedAnchor + public let horizontalAlignment: TerminalOverlayHorizontalAlignment + public let maximumWidthColumns: Int + public let maximumHeightRows: Int + + /// Validates and normalizes producer input. + /// + /// Identifier syntax is deliberately portable across socket clients and + /// config formats. Text retains whitespace and newlines, normalizes CRLF, + /// and drops control bytes that a passive text renderer cannot represent. + public init( + id rawID: String, + text rawText: String, + anchor: TerminalOverlayRequestedAnchor = .viewportTop, + horizontalAlignment: TerminalOverlayHorizontalAlignment = .center, + maximumWidthColumns: Int = Self.defaultMaximumWidthColumns, + maximumHeightRows: Int = Self.defaultMaximumHeightRows + ) throws { + let id = try Self.validatedIdentifier(rawID) + + let text = Self.normalizedText(rawText) + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TerminalOverlayValidationError.emptyText + } + guard text.utf8.count <= Self.maximumTextUTF8Bytes else { + throw TerminalOverlayValidationError.textTooLong( + maxUTF8Bytes: Self.maximumTextUTF8Bytes + ) + } + + self.id = id + self.text = text + self.anchor = anchor + self.horizontalAlignment = horizontalAlignment + self.maximumWidthColumns = max(16, min(200, maximumWidthColumns)) + self.maximumHeightRows = max(1, min(50, maximumHeightRows)) + } + + /// Validates and trims an overlay key for read/remove operations. + public static func validatedIdentifier(_ rawID: String) throws -> String { + let id = rawID.trimmingCharacters(in: .whitespacesAndNewlines) + guard isValidIdentifier(id) else { + throw TerminalOverlayValidationError.invalidIdentifier + } + return id + } + + /// Resolves the request after the host captures any runtime geometry. + public func resolved(anchor: TerminalOverlayAnchor) -> TerminalOverlay { + TerminalOverlay( + id: id, + text: text, + anchor: anchor, + horizontalAlignment: horizontalAlignment, + maximumWidthColumns: maximumWidthColumns, + maximumHeightRows: maximumHeightRows + ) + } + + private static func isValidIdentifier(_ id: String) -> Bool { + guard !id.isEmpty, + id.utf8.count <= maximumIdentifierUTF8Bytes, + let first = id.unicodeScalars.first, + isASCIIAlphaNumeric(first) else { + return false + } + return id.unicodeScalars.allSatisfy { scalar in + isASCIIAlphaNumeric(scalar) || scalar == "." || scalar == "_" || scalar == ":" || scalar == "-" + } + } + + private static func isASCIIAlphaNumeric(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.value { + case 48 ... 57, 65 ... 90, 97 ... 122: + return true + default: + return false + } + } + + private static func normalizedText(_ rawText: String) -> String { + let lineNormalized = rawText + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + var scalars = String.UnicodeScalarView() + for scalar in lineNormalized.unicodeScalars { + if scalar == "\n" || scalar == "\t" || !CharacterSet.controlCharacters.contains(scalar) { + scalars.append(scalar) + } + } + return String(scalars) + } +} + +/// One resolved terminal overlay retained by a terminal surface. +public struct TerminalOverlay: Equatable, Hashable, Sendable { + public let id: String + public let text: String + public let anchor: TerminalOverlayAnchor + public let horizontalAlignment: TerminalOverlayHorizontalAlignment + public let maximumWidthColumns: Int + public let maximumHeightRows: Int + + public init( + id: String, + text: String, + anchor: TerminalOverlayAnchor, + horizontalAlignment: TerminalOverlayHorizontalAlignment, + maximumWidthColumns: Int, + maximumHeightRows: Int + ) { + self.id = id + self.text = text + self.anchor = anchor + self.horizontalAlignment = horizontalAlignment + self.maximumWidthColumns = maximumWidthColumns + self.maximumHeightRows = maximumHeightRows + } +} + +/// Ordered keyed storage for overlays owned by one terminal surface. +public struct TerminalOverlayStore: Equatable, Sendable { + public private(set) var overlays: [TerminalOverlay] + + public init(overlays: [TerminalOverlay] = []) { + self.overlays = [] + for overlay in overlays { + upsert(overlay) + } + } + + /// Replaces a matching key in place, or appends a new key. + public mutating func upsert(_ overlay: TerminalOverlay) { + if let index = overlays.firstIndex(where: { $0.id == overlay.id }) { + overlays[index] = overlay + } else { + overlays.append(overlay) + } + } + + /// Removes one keyed overlay. + @discardableResult + public mutating func remove(id: String) -> Bool { + guard let index = overlays.firstIndex(where: { $0.id == id }) else { + return false + } + overlays.remove(at: index) + return true + } + + /// Removes all overlays and returns the number removed. + @discardableResult + public mutating func removeAll() -> Int { + let count = overlays.count + overlays.removeAll(keepingCapacity: true) + return count + } + + /// Removes scrollback overlays whose absolute row space is no longer valid. + /// + /// Ghostty changes this revision after reflow, reset, or bounded-scrollback + /// eviction. Those operations can renumber rows without exposing a stable + /// row identity, so retaining the old anchor could attach it to unrelated + /// output. + @discardableResult + public mutating func removeInvalidatedScrollbackAnchors( + currentRowSpaceRevision: UInt64 + ) -> [String] { + var removedIDs: [String] = [] + overlays.removeAll { overlay in + guard case .scrollback(_, let revision, _) = overlay.anchor, + revision != currentRowSpaceRevision else { + return false + } + removedIDs.append(overlay.id) + return true + } + return removedIDs + } +} + +/// Where a scrollback overlay belongs for one authoritative viewport snapshot. +public enum TerminalOverlayScrollbackPlacement: Equatable, Sendable { + /// The captured row space or row no longer exists. + case invalidated + + /// A sticky row is below the visible viewport and should not render yet. + case hidden + + /// The strip follows its captured row in the scrollback document. + case document + + /// The captured row is above the viewport, so the strip pins to its top. + case viewportTop +} + +/// Pure placement calculations shared by the AppKit renderer and package tests. +public enum TerminalOverlayGeometry { + public static func horizontalOrigin( + containerWidth: CGFloat, + overlayWidth: CGFloat, + alignment: TerminalOverlayHorizontalAlignment, + margin: CGFloat + ) -> CGFloat { + let availableWidth = max(0, containerWidth) + let width = min(max(0, overlayWidth), availableWidth) + let safeMargin = max(0, min(margin, max(0, (availableWidth - width) / 2))) + switch alignment { + case .left: + return safeMargin + case .center: + return max(0, (availableWidth - width) / 2) + case .right: + return max(0, availableWidth - width - safeMargin) + } + } + + /// Resolves a top-relative Ghostty row into the document view's bottom-up coordinates. + public static func scrollbackOverlayOriginY( + documentHeight: CGFloat, + row: Int, + totalRows: Int, + cellHeight: CGFloat, + topPadding: CGFloat, + overlayHeight: CGFloat + ) -> CGFloat? { + guard row >= 0, + row < totalRows, + documentHeight.isFinite, + cellHeight.isFinite, + cellHeight > 0, + topPadding.isFinite, + overlayHeight.isFinite, + overlayHeight >= 0 else { + return nil + } + return documentHeight - max(0, topPadding) - CGFloat(row) * cellHeight - overlayHeight + } + + /// Resolves sticky and non-sticky behavior without depending on AppKit. + public static func scrollbackPlacement( + row: Int, + capturedRowSpaceRevision: UInt64, + sticksToViewportTop: Bool, + viewportTopRow: Int, + visibleRows: Int, + totalRows: Int, + currentRowSpaceRevision: UInt64 + ) -> TerminalOverlayScrollbackPlacement { + guard capturedRowSpaceRevision == currentRowSpaceRevision, + row >= 0, + row < totalRows else { + return .invalidated + } + guard sticksToViewportTop else { return .document } + guard visibleRows > 0 else { return .hidden } + if row <= viewportTopRow { + return .viewportTop + } + if row - viewportTopRow >= visibleRows { + return .hidden + } + return .document + } + + /// Returns a full terminal-grid strip exactly one cell row high. + public static func gridStripFrame( + containerFrame: CGRect, + columns: Int, + cellSize: CGSize, + leftPadding: CGFloat, + topPadding: CGFloat, + stackIndex: Int + ) -> CGRect? { + guard containerFrame.width.isFinite, + containerFrame.height.isFinite, + columns > 0, + cellSize.width.isFinite, + cellSize.width > 0, + cellSize.height.isFinite, + cellSize.height > 0, + leftPadding.isFinite, + topPadding.isFinite, + stackIndex >= 0 else { + return nil + } + let safeLeftPadding = max(0, leftPadding) + let availableWidth = max(0, containerFrame.width - safeLeftPadding) + let width = min(availableWidth, CGFloat(columns) * cellSize.width) + let originY = containerFrame.maxY + - max(0, topPadding) + - CGFloat(stackIndex + 1) * cellSize.height + guard width > 0, originY >= containerFrame.minY else { return nil } + return CGRect( + x: containerFrame.minX + safeLeftPadding, + y: originY, + width: width, + height: cellSize.height + ) + } +} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalOverlayTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalOverlayTests.swift new file mode 100644 index 000000000000..a94316a0e7ca --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/TerminalOverlayTests.swift @@ -0,0 +1,166 @@ +import CmuxTerminalCore +import Foundation +import Testing + +@Suite struct TerminalOverlayTests { + @Test func validatesAndNormalizesProducerInput() throws { + let request = try TerminalOverlayRequest( + id: "agent.latest-message", + text: "first\r\n\u{001B}[31msecond\u{0000}", + anchor: .scrollbackTop, + horizontalAlignment: .right + ) + + #expect(request.id == "agent.latest-message") + #expect(request.text == "first\n[31msecond") + #expect(request.anchor == .scrollbackTop) + #expect(request.horizontalAlignment == .right) + } + + @Test func rejectsInvalidIdentifiersAndEmptyText() { + #expect(throws: TerminalOverlayValidationError.invalidIdentifier) { + try TerminalOverlayRequest(id: "bad id", text: "content") + } + #expect(throws: TerminalOverlayValidationError.emptyText) { + try TerminalOverlayRequest(id: "valid", text: " \n\t ") + } + } + + @Test func keyedUpsertPreservesOrder() throws { + let first = try TerminalOverlayRequest(id: "first", text: "one") + .resolved(anchor: .viewportTop) + let second = try TerminalOverlayRequest(id: "second", text: "two") + .resolved(anchor: .viewportTop) + let replacement = try TerminalOverlayRequest(id: "first", text: "updated") + .resolved(anchor: .viewportTop) + var store = TerminalOverlayStore() + + store.upsert(first) + store.upsert(second) + store.upsert(replacement) + + #expect(store.overlays.map(\.id) == ["first", "second"]) + #expect(store.overlays.first?.text == "updated") + let removedFirst = store.remove(id: "first") + #expect(removedFirst) + #expect(store.overlays.map(\.id) == ["second"]) + let removedRemaining = store.removeAll() + #expect(removedRemaining == 1) + } + + @Test func resolvesScrollbackRowsInBottomUpDocumentCoordinates() throws { + let origin = try #require(TerminalOverlayGeometry.scrollbackOverlayOriginY( + documentHeight: 8_020, + row: 356, + totalRows: 400, + cellHeight: 20, + topPadding: 10, + overlayHeight: 100 + )) + + #expect(origin == 790) + #expect(TerminalOverlayGeometry.scrollbackOverlayOriginY( + documentHeight: 8_020, + row: 400, + totalRows: 400, + cellHeight: 20, + topPadding: 10, + overlayHeight: 100 + ) == nil) + } + + @Test func removesScrollbackAnchorsAfterRowSpaceInvalidation() throws { + let viewport = try TerminalOverlayRequest(id: "viewport", text: "always") + .resolved(anchor: .viewportTop) + let anchored = try TerminalOverlayRequest(id: "anchored", text: "row") + .resolved(anchor: .scrollback( + row: 20, + rowSpaceRevision: 7, + sticksToViewportTop: false + )) + let sticky = try TerminalOverlayRequest(id: "sticky", text: "pin") + .resolved(anchor: .scrollback( + row: 30, + rowSpaceRevision: 8, + sticksToViewportTop: true + )) + var store = TerminalOverlayStore(overlays: [viewport, anchored, sticky]) + + let removed = store.removeInvalidatedScrollbackAnchors( + currentRowSpaceRevision: 8 + ) + + #expect(removed == ["anchored"]) + #expect(store.overlays.map(\.id) == ["viewport", "sticky"]) + } + + @Test func stickyScrollbackRowsHideFollowAndPin() { + let common = ( + row: 40, + capturedRowSpaceRevision: UInt64(7), + sticksToViewportTop: true, + visibleRows: 20, + totalRows: 100, + currentRowSpaceRevision: UInt64(7) + ) + + #expect(TerminalOverlayGeometry.scrollbackPlacement( + row: common.row, + capturedRowSpaceRevision: common.capturedRowSpaceRevision, + sticksToViewportTop: common.sticksToViewportTop, + viewportTopRow: 10, + visibleRows: common.visibleRows, + totalRows: common.totalRows, + currentRowSpaceRevision: common.currentRowSpaceRevision + ) == .hidden) + #expect(TerminalOverlayGeometry.scrollbackPlacement( + row: common.row, + capturedRowSpaceRevision: common.capturedRowSpaceRevision, + sticksToViewportTop: common.sticksToViewportTop, + viewportTopRow: 30, + visibleRows: common.visibleRows, + totalRows: common.totalRows, + currentRowSpaceRevision: common.currentRowSpaceRevision + ) == .document) + #expect(TerminalOverlayGeometry.scrollbackPlacement( + row: common.row, + capturedRowSpaceRevision: common.capturedRowSpaceRevision, + sticksToViewportTop: common.sticksToViewportTop, + viewportTopRow: 40, + visibleRows: common.visibleRows, + totalRows: common.totalRows, + currentRowSpaceRevision: common.currentRowSpaceRevision + ) == .viewportTop) + #expect(TerminalOverlayGeometry.scrollbackPlacement( + row: common.row, + capturedRowSpaceRevision: common.capturedRowSpaceRevision, + sticksToViewportTop: common.sticksToViewportTop, + viewportTopRow: 41, + visibleRows: common.visibleRows, + totalRows: common.totalRows, + currentRowSpaceRevision: common.currentRowSpaceRevision + ) == .viewportTop) + #expect(TerminalOverlayGeometry.scrollbackPlacement( + row: common.row, + capturedRowSpaceRevision: common.capturedRowSpaceRevision, + sticksToViewportTop: common.sticksToViewportTop, + viewportTopRow: 41, + visibleRows: common.visibleRows, + totalRows: common.totalRows, + currentRowSpaceRevision: 8 + ) == .invalidated) + } + + @Test func stripFrameMatchesOneTerminalGridRow() throws { + let frame = try #require(TerminalOverlayGeometry.gridStripFrame( + containerFrame: CGRect(x: 12, y: 20, width: 820, height: 500), + columns: 80, + cellSize: CGSize(width: 10, height: 21), + leftPadding: 5, + topPadding: 7, + stackIndex: 1 + )) + + #expect(frame == CGRect(x: 17, y: 471, width: 800, height: 21)) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index b59f1297fd91..c12d1e660ca4 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -35186,6 +35186,176 @@ } } }, + "cli.surfaceOverlay.error.missingSubcommand": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay requires set, list, remove, or clear" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay には set、list、remove、または clear が必要です" + } + } + } + }, + "cli.surfaceOverlay.error.removeExtraArguments": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay remove accepts one id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay remove には 1 つの ID を指定できます" + } + } + } + }, + "cli.surfaceOverlay.error.removeRequiresID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay remove requires an id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay remove には ID が必要です" + } + } + } + }, + "cli.surfaceOverlay.error.setRequiresID": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay set requires an id" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay set には ID が必要です" + } + } + } + }, + "cli.surfaceOverlay.error.setRequiresText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay set requires text or '-' for standard input" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay set にはテキスト、または標準入力を表す '-' が必要です" + } + } + } + }, + "cli.surfaceOverlay.error.unexpectedArgumentFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay %@: unexpected argument '%@'" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay %@: 予期しない引数 '%@'" + } + } + } + }, + "cli.surfaceOverlay.error.unknownFlagFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay: unknown flag '%@'" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay: 不明なフラグ '%@'" + } + } + } + }, + "cli.surfaceOverlay.error.unsupportedSubcommandFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unsupported surface overlay subcommand: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未対応の surface overlay サブコマンド: %@" + } + } + } + }, + "cli.surfaceOverlay.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux surface overlay set <id> <text> [--anchor <viewport|scrollback|sticky>] [--position <left|center|right>] [target flags]\n cmux surface overlay list [target flags]\n cmux surface overlay remove <id> [target flags]\n cmux surface overlay clear [target flags]\n\nRender passive one-row strips over a terminal without taking keyboard or mouse input.\nViewport stays at the visible top. Scrollback stays at the captured top row.\nSticky follows the captured row, then pins when that row reaches the viewport top.\n\nTarget flags:\n --workspace <id|ref|index> Workspace context (default: $CMUX_WORKSPACE_ID)\n --surface <id|ref|index> Terminal context (default: $CMUX_SURFACE_ID)\n --window <id|ref|index> Window context for workspace and surface refs/indexes\n\nSet flags:\n --anchor <viewport|scrollback|sticky> Vertical anchor (default: viewport)\n --position <left|center|right> Text alignment (default: center)\n\nUse '-' as text to read the overlay from standard input.\n\nExamples:\n cmux surface overlay set latest-message \"check the auth error\"\n printf 'build\\npassed' | cmux surface overlay set build-status - --position right\n cmux surface overlay set review-note \"inspect this output\" --anchor scrollback --position left\n cmux surface overlay set latest-message \"keep this visible\" --anchor sticky --position left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使用方法: cmux surface overlay set <id> <text> [--anchor <viewport|scrollback|sticky>] [--position <left|center|right>] [対象フラグ]\n cmux surface overlay list [対象フラグ]\n cmux surface overlay remove <id> [対象フラグ]\n cmux surface overlay clear [対象フラグ]\n\nキーボードやマウス入力を奪わず、ターミナル上に受動的な1行ストリップを表示します。\nviewport は表示領域の上端に残ります。scrollback は取得した上端行に残ります。\nsticky は取得した行を追従し、その行が表示領域の上端に達すると固定されます。\n\n対象フラグ:\n --workspace <id|ref|index> ワークスペース (既定: $CMUX_WORKSPACE_ID)\n --surface <id|ref|index> ターミナル (既定: $CMUX_SURFACE_ID)\n --window <id|ref|index> ワークスペースとサーフェスの参照・インデックスに使うウインドウ\n\nset フラグ:\n --anchor <viewport|scrollback|sticky> 垂直アンカー (既定: viewport)\n --position <left|center|right> テキスト配置 (既定: center)\n\nテキストに '-' を指定すると標準入力から読み取ります。\n\n例:\n cmux surface overlay set latest-message \"check the auth error\"\n printf 'build\\npassed' | cmux surface overlay set build-status - --position right\n cmux surface overlay set review-note \"inspect this output\" --anchor scrollback --position left\n cmux surface overlay set latest-message \"keep this visible\" --anchor sticky --position left" + } + } + } + }, + "cli.surfaceOverlay.usageLine": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "surface overlay <set|list|remove|clear> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "surface overlay <set|list|remove|clear> [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]" + } + } + } + }, "cli.surface.error.handleBlank": { "extractionState": "manual", "localizations": { @@ -230163,6 +230333,193 @@ } } }, + "socket.surfaceOverlay.error.emptyText": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overlay text cannot be empty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーバーレイのテキストを空にはできません" + } + } + } + }, + "socket.surfaceOverlay.error.invalidAnchorFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown overlay anchor '%@'; use viewport, scrollback, or sticky" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なオーバーレイアンカー '%@' です。viewport、scrollback、または sticky を使用してください" + } + } + } + }, + "socket.surfaceOverlay.error.invalidIdentifier": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overlay id must start with a letter or number and use at most 128 letters, numbers, dots, dashes, underscores, or colons" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーバーレイ ID は英数字で始め、128 文字以内の英数字、ピリオド、ダッシュ、アンダースコア、またはコロンを使用してください" + } + } + } + }, + "socket.surfaceOverlay.error.invalidPositionFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown overlay position '%@'; use left, center, or right" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なオーバーレイ位置 '%@' です。left、center、または right を使用してください" + } + } + } + }, + "socket.surfaceOverlay.error.noFocusedSurface": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No focused surface" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォーカス中のサーフェスがありません" + } + } + } + }, + "socket.surfaceOverlay.error.scrollbackUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scrollback geometry is not available yet; retry after the terminal renders" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スクロールバックの配置情報をまだ利用できません。ターミナルの描画後に再試行してください" + } + } + } + }, + "socket.surfaceOverlay.error.surfaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスが見つかりません" + } + } + } + }, + "socket.surfaceOverlay.error.surfaceNotTerminal": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface is not a terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サーフェスはターミナルではありません" + } + } + } + }, + "socket.surfaceOverlay.error.textTooLongFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Overlay text cannot exceed %lld UTF-8 bytes" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オーバーレイのテキストは %lld UTF-8 バイト以下にしてください" + } + } + } + }, + "socket.surfaceOverlay.error.windowUnavailable": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "cmux window is not available. Reopen the window and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "cmux ウインドウを利用できません。ウインドウを開き直して再試行してください。" + } + } + } + }, + "socket.surfaceOverlay.error.workspaceNotFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースが見つかりません" + } + } + } + }, "socket.surface.create.remoteTmuxPaneUnsupportedType": { "extractionState": "manual", "localizations": { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 20c2af815f51..77d51311b303 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -8197,8 +8197,8 @@ final class GhosttySurfaceScrollView: NSView { private var sharedBackdropCutoutView: NSView? private let backgroundView: TerminalPaneBackgroundView - private let scrollView: GhosttyScrollView - private let documentView: NSView + let scrollView: GhosttyScrollView + let documentView: NSView let surfaceView: GhosttyNSView private let mobileViewportBorderOverlayView = TerminalViewportBorderOverlayView(frame: .zero) private let inactiveOverlayView: GhosttyFlashOverlayView @@ -8236,6 +8236,8 @@ final class GhosttySurfaceScrollView: NSView { private let imageTransferIndicatorSpinner: NSProgressIndicator private let imageTransferCancelButton: NSButton private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>? + var terminalOverlayViews: [String: TerminalOverlayLineView] = [:] + var renderedTerminalOverlays: [TerminalOverlay] = [] private let deferredSearchOverlayMutationScheduler = MainActorDeferredActionScheduler() private let imageTransferIndicatorShowScheduler = MainActorDeferredActionScheduler() private var activeImageTransferOperation: TerminalImageTransferOperation? @@ -8790,6 +8792,7 @@ final class GhosttySurfaceScrollView: NSView { queue: .main ) { [weak self] _ in self?.synchronizeScrollView() + self?.synchronizeTerminalOverlays() }) observers.append(NotificationCenter.default.addObserver( @@ -9000,6 +9003,7 @@ final class GhosttySurfaceScrollView: NSView { updateFlashAppearance(style: lastFlashStyle) synchronizeScrollView() synchronizeSurfaceView() + synchronizeTerminalOverlays() let didCoreSurfaceChange = synchronizeCoreSurface() return !sizeApproximatelyEqual(previousSurfaceSize, targetSize) || didCoreSurfaceChange } @@ -9011,7 +9015,7 @@ final class GhosttySurfaceScrollView: NSView { _ = synchronizeGeometryAndContent() } - private var sessionContentFrame: CGRect { + var sessionContentFrame: CGRect { sessionContentWidthPresentation.contentFrame(in: bounds) } @@ -11598,6 +11602,7 @@ final class GhosttySurfaceScrollView: NSView { return } synchronizeScrollView() + synchronizeTerminalOverlays() restorePendingNotificationScrollPositionAfterScrollbarUpdate() } @@ -11645,7 +11650,7 @@ final class GhosttySurfaceScrollView: NSView { _ = synchronizeGeometryAndContent() } - private func documentHeight() -> CGFloat { + func documentHeight() -> CGFloat { let contentHeight = scrollView.contentSize.height let cellHeight = surfaceView.cellSize.height if cellHeight > 0, let scrollbar = surfaceView.scrollbar { diff --git a/Sources/TerminalController+ControlSurfaceOverlay.swift b/Sources/TerminalController+ControlSurfaceOverlay.swift new file mode 100644 index 000000000000..cb7340a77850 --- /dev/null +++ b/Sources/TerminalController+ControlSurfaceOverlay.swift @@ -0,0 +1,249 @@ +import CmuxControlSocket +import CmuxTerminalCore +import Foundation + +extension TerminalController { + func controlSurfaceOverlayStrings() -> ControlSurfaceOverlayStrings { + ControlSurfaceOverlayStrings( + tabManagerUnavailable: String( + localized: "socket.surfaceOverlay.error.windowUnavailable", + defaultValue: "cmux window is not available. Reopen the window and try again." + ), + workspaceNotFound: String( + localized: "socket.surfaceOverlay.error.workspaceNotFound", + defaultValue: "Workspace not found" + ), + surfaceNotFound: String( + localized: "socket.surfaceOverlay.error.surfaceNotFound", + defaultValue: "Surface not found" + ), + noFocusedSurface: String( + localized: "socket.surfaceOverlay.error.noFocusedSurface", + defaultValue: "No focused surface" + ), + surfaceNotTerminal: String( + localized: "socket.surfaceOverlay.error.surfaceNotTerminal", + defaultValue: "Surface is not a terminal" + ), + invalidIdentifier: String( + localized: "socket.surfaceOverlay.error.invalidIdentifier", + defaultValue: "Overlay id must start with a letter or number and use at most 128 letters, numbers, dots, dashes, underscores, or colons" + ), + emptyText: String( + localized: "socket.surfaceOverlay.error.emptyText", + defaultValue: "Overlay text cannot be empty" + ), + textTooLongFormat: String( + localized: "socket.surfaceOverlay.error.textTooLongFormat", + defaultValue: "Overlay text cannot exceed %lld UTF-8 bytes" + ), + invalidAnchorFormat: String( + localized: "socket.surfaceOverlay.error.invalidAnchorFormat", + defaultValue: "Unknown overlay anchor '%@'; use viewport, scrollback, or sticky" + ), + invalidAlignmentFormat: String( + localized: "socket.surfaceOverlay.error.invalidPositionFormat", + defaultValue: "Unknown overlay position '%@'; use left, center, or right" + ), + scrollbackUnavailable: String( + localized: "socket.surfaceOverlay.error.scrollbackUnavailable", + defaultValue: "Scrollback geometry is not available yet; retry after the terminal renders" + ) + ) + } + + func controlSurfaceOverlay( + routing: ControlRoutingSelectors, + surfaceID: UUID?, + hasSurfaceIDParam: Bool, + action: ControlSurfaceOverlayAction + ) -> ControlSurfaceOverlayResolution { + guard let tabManager = resolveTabManager(routing: routing) else { + return .tabManagerUnavailable + } + + if let dock = windowDockForRouting(routing, tabManager: tabManager) { + let target = terminalPanel( + in: dock, + explicitSurfaceID: surfaceID, + hasSurfaceIDParam: hasSurfaceIDParam, + routing: routing + ) + if target.invalidSurfaceID { + return .surfaceNotFound + } + guard let resolvedSurfaceID = target.surfaceID else { + return .noFocusedSurface + } + guard let terminalPanel = target.terminalPanel else { + return dock.panels[resolvedSurfaceID] == nil + ? .surfaceNotFound + : .surfaceNotTerminal(resolvedSurfaceID) + } + return performControlSurfaceOverlay( + action, + panel: terminalPanel, + windowID: dockResultWindowId(for: dock, tabManager: tabManager), + workspaceID: dock.workspaceId, + surfaceID: resolvedSurfaceID + ) + } + + guard let workspace = resolveSurfaceWorkspace(routing: routing, tabManager: tabManager) else { + return .workspaceNotFound + } + if hasSurfaceIDParam, surfaceID == nil { + return .surfaceNotFound + } + + let resolvedSurfaceID: UUID + let terminalPanel: TerminalPanel + if let surfaceID { + guard let target = workspace.controlSurfaceTarget(for: surfaceID) else { + return .surfaceNotFound + } + guard let panel = target.panel as? TerminalPanel else { + return .surfaceNotTerminal(target.surfaceID) + } + resolvedSurfaceID = target.surfaceID + terminalPanel = panel + } else { + guard let target = workspace.controlDefaultTerminalTarget(paneID: routing.paneID) else { + return .noFocusedSurface + } + resolvedSurfaceID = target.surfaceID + terminalPanel = target.panel + } + + return performControlSurfaceOverlay( + action, + panel: terminalPanel, + windowID: v2ResolveWindowId(tabManager: tabManager), + workspaceID: workspace.id, + surfaceID: resolvedSurfaceID + ) + } + + private func performControlSurfaceOverlay( + _ action: ControlSurfaceOverlayAction, + panel: TerminalPanel, + windowID: UUID?, + workspaceID: UUID, + surfaceID: UUID + ) -> ControlSurfaceOverlayResolution { + switch action { + case .list: + return .listed( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID, + overlays: panel.surface.terminalOverlays().map(controlSurfaceOverlaySnapshot) + ) + + case .set(let inputs): + let request: TerminalOverlayRequest + do { + let anchor: TerminalOverlayRequestedAnchor = switch inputs.anchor { + case .viewportTop: .viewportTop + case .scrollbackTop: .scrollbackTop + case .scrollbackSticky: .scrollbackSticky + } + request = try TerminalOverlayRequest( + id: inputs.id, + text: inputs.text, + anchor: anchor, + horizontalAlignment: terminalOverlayAlignment(inputs.alignment) + ) + } catch let error as TerminalOverlayValidationError { + return .validationFailed(controlSurfaceOverlayValidationError(error)) + } catch { + return .validationFailed(.invalidIdentifier) + } + switch panel.surface.upsertTerminalOverlay(request) { + case .success(let overlay): + return .set( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID, + overlay: controlSurfaceOverlaySnapshot(overlay) + ) + case .failure(.scrollbackGeometryUnavailable): + return .scrollbackUnavailable(surfaceID) + } + + case .remove(let rawID): + let id: String + do { + id = try TerminalOverlayRequest.validatedIdentifier(rawID) + } catch { + return .validationFailed(.invalidIdentifier) + } + return .removed( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID, + overlayID: id, + removed: panel.surface.removeTerminalOverlay(id: id) + ) + + case .clear: + return .cleared( + windowID: windowID, + workspaceID: workspaceID, + surfaceID: surfaceID, + removedCount: panel.surface.removeAllTerminalOverlays() + ) + } + } + + private func terminalOverlayAlignment( + _ alignment: ControlSurfaceOverlayAlignment + ) -> TerminalOverlayHorizontalAlignment { + switch alignment { + case .left: .left + case .center: .center + case .right: .right + } + } + + private func controlSurfaceOverlayValidationError( + _ error: TerminalOverlayValidationError + ) -> ControlSurfaceOverlayValidationError { + switch error { + case .invalidIdentifier: + .invalidIdentifier + case .emptyText: + .emptyText + case .textTooLong(let maxUTF8Bytes): + .textTooLong(maxUTF8Bytes: maxUTF8Bytes) + } + } + + private func controlSurfaceOverlaySnapshot( + _ overlay: TerminalOverlay + ) -> ControlSurfaceOverlaySnapshot { + let alignment: ControlSurfaceOverlayAlignment = switch overlay.horizontalAlignment { + case .left: .left + case .center: .center + case .right: .right + } + switch overlay.anchor { + case .viewportTop: + return ControlSurfaceOverlaySnapshot( + id: overlay.id, + text: overlay.text, + anchor: .viewportTop, + alignment: alignment + ) + case .scrollback(let row, let rowSpaceRevision, let sticksToViewportTop): + return ControlSurfaceOverlaySnapshot( + id: overlay.id, + text: overlay.text, + anchor: sticksToViewportTop ? .scrollbackSticky : .scrollbackTop, + alignment: alignment, + scrollbackRow: row, + rowSpaceRevision: rowSpaceRevision + ) + } + } +} diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index f2ed8d94272e..f2d84d389b29 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2602,6 +2602,10 @@ class TerminalController { "surface.read_text", "surface.clear_history", "surface.trigger_flash", + "surface.overlay.list", + "surface.overlay.set", + "surface.overlay.remove", + "surface.overlay.clear", "pane.list", "pane.focus", "pane.surfaces", diff --git a/Sources/TerminalOverlayView.swift b/Sources/TerminalOverlayView.swift new file mode 100644 index 000000000000..f3ac6d943bea --- /dev/null +++ b/Sources/TerminalOverlayView.swift @@ -0,0 +1,311 @@ +import AppKit +import CmuxTerminal +import CmuxTerminalCore +import GhosttyKit + +@MainActor +final class TerminalOverlayLineView: NSVisualEffectView { + private let label = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + material = .hudWindow + blendingMode = .withinWindow + state = .active + alphaValue = 0.92 + + label.isEditable = false + label.isSelectable = false + label.drawsBackground = false + label.isBezeled = false + label.usesSingleLineMode = true + label.lineBreakMode = .byTruncatingTail + label.cell?.wraps = false + label.textColor = .labelColor + addSubview(label) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) not implemented") + } + + override var acceptsFirstResponder: Bool { false } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func apply( + _ overlay: TerminalOverlay, + cellSize: CGSize, + stripWidth: CGFloat + ) -> CGSize { + let cellWidth = cellSize.width > 0 ? cellSize.width : 8 + let cellHeight = cellSize.height > 0 ? cellSize.height : 18 + guard stripWidth > 0 else { return .zero } + let horizontalInset = min(cellWidth, stripWidth / 4) + let availableContentWidth = max(0, stripWidth - horizontalInset * 2) + let contentWidth = min( + availableContentWidth, + CGFloat(overlay.maximumWidthColumns) * cellWidth + ) + guard contentWidth > 0 else { return .zero } + let contentOriginX: CGFloat + switch overlay.horizontalAlignment { + case .left: + contentOriginX = horizontalInset + label.alignment = .left + case .center: + contentOriginX = max(0, (stripWidth - contentWidth) / 2) + label.alignment = .center + case .right: + contentOriginX = max(0, stripWidth - horizontalInset - contentWidth) + label.alignment = .right + } + + let displayText = overlay.text + .split(whereSeparator: { $0.isWhitespace }) + .joined(separator: " ") + label.stringValue = displayText + label.font = NSFont.monospacedSystemFont( + ofSize: max(9, min(18, cellHeight * 0.65)), + weight: .regular + ) + label.maximumNumberOfLines = 1 + label.frame = CGRect( + x: contentOriginX, + y: 0, + width: contentWidth, + height: cellHeight + ) + label.identifier = NSUserInterfaceItemIdentifier("terminal-overlay-\(overlay.id)") + label.setAccessibilityIdentifier("terminal-overlay-\(overlay.id)") + label.setAccessibilityLabel(displayText) + return CGSize(width: stripWidth, height: cellHeight) + } +} + +private struct TerminalOverlayGridMetrics { + let columns: Int + let cellSize: CGSize + let leftPadding: CGFloat + let topPadding: CGFloat +} + +private struct ScrollbackOverlayStackKey: Hashable { + let row: Int + let rowSpaceRevision: UInt64 +} + +private struct ScrollbackOverlayLinePlacement { + let line: TerminalOverlayLineView + let row: Int + let rowSpaceRevision: UInt64 + let size: CGSize +} + +@MainActor +extension GhosttyNSView { + fileprivate func terminalOverlayGridMetrics() -> TerminalOverlayGridMetrics? { + guard let surface = terminalSurface?.liveSurfaceForGhosttyAccess( + reason: "terminalOverlay.gridMetrics" + ) else { return nil } + var native = ghostty_surface_grid_metrics_s() + guard ghostty_surface_grid_metrics(surface, &native), + native.cell_width.isFinite, + native.cell_width > 0, + native.cell_height.isFinite, + native.cell_height > 0, + native.padding_left.isFinite, + native.padding_top.isFinite else { + return nil + } + return TerminalOverlayGridMetrics( + columns: Int(native.columns), + cellSize: CGSize( + width: CGFloat(native.cell_width), + height: CGFloat(native.cell_height) + ), + leftPadding: max(0, CGFloat(native.padding_left)), + topPadding: max(0, CGFloat(native.padding_top)) + ) + } +} + +@MainActor +extension GhosttySurfaceScrollView { + func captureTerminalOverlayScrollbackAnchor( + sticksToViewportTop: Bool + ) -> TerminalOverlayAnchor? { + guard let geometry = surfaceView.authoritativeScrollbarGeometry(), + geometry.scrollbar.len > 0 else { + return nil + } + return .scrollback( + row: Int(clamping: geometry.scrollbar.offset), + rowSpaceRevision: geometry.rowSpaceRevision, + sticksToViewportTop: sticksToViewportTop + ) + } + + func setTerminalOverlays(_ overlays: [TerminalOverlay]) { + renderedTerminalOverlays = overlays + let validIDs = Set(overlays.map(\.id)) + let removedIDs = terminalOverlayViews.keys.filter { !validIDs.contains($0) } + for id in removedIDs { + terminalOverlayViews[id]?.removeFromSuperview() + terminalOverlayViews.removeValue(forKey: id) + } + for overlay in overlays where terminalOverlayViews[overlay.id] == nil { + terminalOverlayViews[overlay.id] = TerminalOverlayLineView(frame: .zero) + } + synchronizeTerminalOverlays() + } + + func synchronizeTerminalOverlays() { + guard !renderedTerminalOverlays.isEmpty else { return } + + let gridMetrics = surfaceView.terminalOverlayGridMetrics() + let cellSize = gridMetrics?.cellSize ?? surfaceView.cellSize + guard cellSize.width > 0, cellSize.height > 0 else { return } + let fallbackColumns = max(1, Int(sessionContentFrame.width / cellSize.width)) + let columns = gridMetrics?.columns ?? fallbackColumns + let leftPadding = gridMetrics?.leftPadding ?? 0 + let topPadding = gridMetrics?.topPadding ?? 0 + var viewportLines: [TerminalOverlayLineView] = [] + var scrollbackLines: [ScrollbackOverlayLinePlacement] = [] + let scrollbackGeometry = renderedTerminalOverlays.contains(where: { + if case .scrollback = $0.anchor { return true } + return false + }) ? surfaceView.authoritativeScrollbarGeometry() : nil + + if let scrollbackGeometry, + renderedTerminalOverlays.contains(where: { overlay in + guard case .scrollback(_, let revision, _) = overlay.anchor else { return false } + return revision != scrollbackGeometry.rowSpaceRevision + }), + let terminalSurface = surfaceView.terminalSurface, + !terminalSurface.removeInvalidatedTerminalOverlayAnchors( + currentRowSpaceRevision: scrollbackGeometry.rowSpaceRevision + ).isEmpty { + return + } + + for overlay in renderedTerminalOverlays { + guard let line = terminalOverlayViews[overlay.id] else { continue } + let containerWidth: CGFloat + switch overlay.anchor { + case .viewportTop: + containerWidth = sessionContentFrame.width + case .scrollback: + containerWidth = documentView.bounds.width + } + let stripWidth = min( + max(0, containerWidth - leftPadding), + CGFloat(columns) * cellSize.width + ) + let lineSize = line.apply( + overlay, + cellSize: cellSize, + stripWidth: stripWidth + ) + guard lineSize.width > 0, lineSize.height > 0 else { + line.isHidden = true + continue + } + + switch overlay.anchor { + case .viewportTop: + attachTerminalOverlayLineToViewport(line) + viewportLines.append(line) + + case .scrollback(let row, let rowSpaceRevision, let sticksToViewportTop): + guard let geometry = scrollbackGeometry, + geometry.scrollbar.len > 0 else { + line.isHidden = true + continue + } + let placement = TerminalOverlayGeometry.scrollbackPlacement( + row: row, + capturedRowSpaceRevision: rowSpaceRevision, + sticksToViewportTop: sticksToViewportTop, + viewportTopRow: Int(clamping: geometry.scrollbar.offset), + visibleRows: Int(clamping: geometry.scrollbar.len), + totalRows: Int(clamping: geometry.scrollbar.total), + currentRowSpaceRevision: geometry.rowSpaceRevision + ) + switch placement { + case .invalidated, .hidden: + line.isHidden = true + + case .viewportTop: + attachTerminalOverlayLineToViewport(line) + viewportLines.append(line) + + case .document: + if line.superview !== documentView { + line.removeFromSuperview() + documentView.addSubview(line, positioned: .above, relativeTo: surfaceView) + } + scrollbackLines.append(ScrollbackOverlayLinePlacement( + line: line, + row: row, + rowSpaceRevision: rowSpaceRevision, + size: lineSize + )) + } + } + } + + for (stackIndex, line) in viewportLines.enumerated() { + guard let frame = TerminalOverlayGeometry.gridStripFrame( + containerFrame: sessionContentFrame, + columns: columns, + cellSize: cellSize, + leftPadding: leftPadding, + topPadding: topPadding, + stackIndex: stackIndex + ) else { + line.isHidden = true + continue + } + line.frame = frame + line.isHidden = false + } + + var scrollbackStackIndexes: [ScrollbackOverlayStackKey: Int] = [:] + for placement in scrollbackLines { + let stackKey = ScrollbackOverlayStackKey( + row: placement.row, + rowSpaceRevision: placement.rowSpaceRevision + ) + let stackIndex = scrollbackStackIndexes[stackKey, default: 0] + guard let originY = TerminalOverlayGeometry.scrollbackOverlayOriginY( + documentHeight: documentHeight(), + row: placement.row, + totalRows: Int(clamping: scrollbackGeometry?.scrollbar.total ?? 0), + cellHeight: cellSize.height, + topPadding: topPadding, + overlayHeight: placement.size.height + ) else { + placement.line.isHidden = true + continue + } + placement.line.frame = CGRect( + x: leftPadding, + y: originY - CGFloat(stackIndex) * cellSize.height, + width: placement.size.width, + height: placement.size.height + ) + placement.line.isHidden = false + scrollbackStackIndexes[stackKey] = stackIndex + 1 + } + } + + private func attachTerminalOverlayLineToViewport(_ line: TerminalOverlayLineView) { + if line.superview !== self { + line.removeFromSuperview() + addSubview(line, positioned: .above, relativeTo: scrollView) + } + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3880c46f072e..58c18d39b89d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -683,6 +683,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 */; }; + F10A00000000000000000001 /* CMUXCLI+TerminalOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10A00000000000000000002 /* CMUXCLI+TerminalOverlay.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 */; }; @@ -2185,6 +2186,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C0DE00000000000000000C6A /* TerminalController+ControlSurfaceContext4.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C69 /* TerminalController+ControlSurfaceContext4.swift */; }; C0DE00000000000000000C86 /* TerminalController+ControlSurfaceDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C85 /* TerminalController+ControlSurfaceDock.swift */; }; 8126BEEF0000000000000001 /* TerminalController+ControlSurfaceGitContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8126BEEF0000000000000002 /* TerminalController+ControlSurfaceGitContext.swift */; }; + F10A00000000000000000005 /* TerminalController+ControlSurfaceOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10A00000000000000000006 /* TerminalController+ControlSurfaceOverlay.swift */; }; C51A73000000000000000301 /* TerminalController+ControlSurfacePanelType.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A73000000000000000302 /* TerminalController+ControlSurfacePanelType.swift */; }; C0DE00000000000000000C7A /* TerminalController+ControlSystemContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C79 /* TerminalController+ControlSystemContext.swift */; }; C0DE00000000000000000C7C /* TerminalController+ControlSystemContext2.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C7B /* TerminalController+ControlSystemContext2.swift */; }; @@ -2279,6 +2281,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources A5001095 /* TerminalNotificationStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001092 /* TerminalNotificationStore.swift */; }; 0A110711000000000000001C /* TerminalOutputTeeCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A110711000000000000001D /* TerminalOutputTeeCallback.swift */; }; 0A110711000000000000001E /* TerminalOutputTeeContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A110711000000000000001F /* TerminalOutputTeeContext.swift */; }; + F10A00000000000000000003 /* TerminalOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10A00000000000000000004 /* TerminalOverlayView.swift */; }; C71500060000000000000001 /* TerminalPaneBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71500050000000000000001 /* TerminalPaneBackgroundView.swift */; }; D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */; }; 8997E0048997E0048997E004 /* TerminalPanel+AgentHibernation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8997F0048997F0048997F004 /* TerminalPanel+AgentHibernation.swift */; }; @@ -3344,6 +3347,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>"; }; + F10A00000000000000000002 /* CMUXCLI+TerminalOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TerminalOverlay.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>"; }; @@ -4768,6 +4772,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE00000000000000000C69 /* TerminalController+ControlSurfaceContext4.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceContext4.swift"; sourceTree = "<group>"; }; C0DE00000000000000000C85 /* TerminalController+ControlSurfaceDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceDock.swift"; sourceTree = "<group>"; }; 8126BEEF0000000000000002 /* TerminalController+ControlSurfaceGitContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceGitContext.swift"; sourceTree = "<group>"; }; + F10A00000000000000000006 /* TerminalController+ControlSurfaceOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfaceOverlay.swift"; sourceTree = "<group>"; }; C51A73000000000000000302 /* TerminalController+ControlSurfacePanelType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSurfacePanelType.swift"; sourceTree = "<group>"; }; C0DE00000000000000000C79 /* TerminalController+ControlSystemContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSystemContext.swift"; sourceTree = "<group>"; }; C0DE00000000000000000C7B /* TerminalController+ControlSystemContext2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlSystemContext2.swift"; sourceTree = "<group>"; }; @@ -4862,6 +4867,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001092 /* TerminalNotificationStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalNotificationStore.swift; sourceTree = "<group>"; }; 0A110711000000000000001D /* TerminalOutputTeeCallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalOutputTeeCallback.swift; sourceTree = "<group>"; }; 0A110711000000000000001F /* TerminalOutputTeeContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalOutputTeeContext.swift; sourceTree = "<group>"; }; + F10A00000000000000000004 /* TerminalOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalOverlayView.swift; sourceTree = "<group>"; }; C71500050000000000000001 /* TerminalPaneBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneBackgroundView.swift; sourceTree = "<group>"; }; D0B10001A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalPaneDropTargetView.swift; sourceTree = "<group>"; }; 8997F0048997F0048997F004 /* TerminalPanel+AgentHibernation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/TerminalPanel+AgentHibernation.swift"; sourceTree = "<group>"; }; @@ -6159,6 +6165,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DEF828300000000000002 /* GhosttyTerminalImmediateHostedStateAction.swift */, E89950010000000000000002 /* GhosttyFlashOverlayView.swift */, A5001015 /* GhosttyTerminalView.swift */, + F10A00000000000000000004 /* TerminalOverlayView.swift */, E89950040000000000000002 /* KeyboardCopyModeGridMetrics.swift */, E89950030000000000000002 /* KeyboardCopyModeResolvedCell.swift */, E89950020000000000000002 /* KeyboardCopyModeSelectionKind.swift */, @@ -6289,6 +6296,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C51A73000000000000000302 /* TerminalController+ControlSurfacePanelType.swift */, C0DE00000000000000000C67 /* TerminalController+ControlSurfaceContext3.swift */, C0DE00000000000000000C69 /* TerminalController+ControlSurfaceContext4.swift */, + F10A00000000000000000006 /* TerminalController+ControlSurfaceOverlay.swift */, 8126BEEF0000000000000002 /* TerminalController+ControlSurfaceGitContext.swift */, C0DE00000000000000000C85 /* TerminalController+ControlSurfaceDock.swift */, C0DE00000000000000000C51 /* TerminalController+ControlMobileHostContext.swift */, @@ -7187,6 +7195,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 89930005AABBCCDDEEFF0002 /* ClaudeHookSessionStore+SupersededCleanup.swift */, 89930004AABBCCDDEEFF0002 /* ClaudeHookSessionStoreFile.swift */, 61AD48A9E6C3F1BE2B547FFE /* CMUXCLI+AgentHookCatalog.swift */, + F10A00000000000000000002 /* CMUXCLI+TerminalOverlay.swift */, 918100000000000000000010 /* CMUXCLI+AgentHookFailureReporting.swift */, 918100000000000000000020 /* CodexTranscriptMonitorStopReplay.swift */, 918100000000000000000040 /* CodexHookFailureSummary.swift */, @@ -9750,6 +9759,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C0DE00000000000000000C6A /* TerminalController+ControlSurfaceContext4.swift in Sources */, C0DE00000000000000000C86 /* TerminalController+ControlSurfaceDock.swift in Sources */, 8126BEEF0000000000000001 /* TerminalController+ControlSurfaceGitContext.swift in Sources */, + F10A00000000000000000005 /* TerminalController+ControlSurfaceOverlay.swift in Sources */, C51A73000000000000000301 /* TerminalController+ControlSurfacePanelType.swift in Sources */, C0DE00000000000000000C7A /* TerminalController+ControlSystemContext.swift in Sources */, C0DE00000000000000000C7C /* TerminalController+ControlSystemContext2.swift in Sources */, @@ -9832,6 +9842,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = A5001095 /* TerminalNotificationStore.swift in Sources */, 0A110711000000000000001C /* TerminalOutputTeeCallback.swift in Sources */, 0A110711000000000000001E /* TerminalOutputTeeContext.swift in Sources */, + F10A00000000000000000003 /* TerminalOverlayView.swift in Sources */, C71500060000000000000001 /* TerminalPaneBackgroundView.swift in Sources */, D0B10000A1B2C3D4E5F60001 /* TerminalPaneDropTargetView.swift in Sources */, 8997E0048997E0048997E004 /* TerminalPanel+AgentHibernation.swift in Sources */, @@ -10172,6 +10183,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C77070000000000000000008 /* CMUXCLI+SSHPTYAttachBridge.swift in Sources */, 6702F211735472DC14581CA7 /* CMUXCLI+SSHReconnectPrompt.swift in Sources */, CBF07000000000000000002 /* CMUXCLI+SSHStartupScripts.swift in Sources */, + F10A00000000000000000001 /* CMUXCLI+TerminalOverlay.swift in Sources */, B900002EA1B2C3D4E5F60719 /* CMUXCLI+Themes.swift in Sources */, B900002FA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift in Sources */, B9000048A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatHUDSupport.swift in Sources */, diff --git a/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift b/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift index 86dd149ecac3..ca840c8f29ce 100644 --- a/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift +++ b/cmuxTests/CLICodexHookTimeoutRegressionTestSupport.swift @@ -56,11 +56,17 @@ func makeCodexHookExecutableShellFile(at url: URL, lines: [String]) throws { final class CodexHookCapturedSocketCommands: @unchecked Sendable { private let lock = NSLock() private var commands: [String] = [] + private let onAppend: (@Sendable (String) -> Void)? + + init(onAppend: (@Sendable (String) -> Void)? = nil) { + self.onAppend = onAppend + } func append(_ command: String) { lock.lock() commands.append(command) lock.unlock() + onAppend?(command) } func snapshot() -> [String] { diff --git a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift index 30fc5327875f..6a6e3ba08e63 100644 --- a/cmuxTests/CLICodexHookTimeoutRegressionTests.swift +++ b/cmuxTests/CLICodexHookTimeoutRegressionTests.swift @@ -107,9 +107,11 @@ struct CLICodexHookTimeoutRegressionTests { "HOME": root.path, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", "TMPDIR": root.path, + "CMUX_WORKSPACE_ID": "workspace-123", "CMUX_SURFACE_ID": "surface-123", "CMUX_SOCKET_PATH": "/tmp/cmux-test.sock", - "CMUX_BUNDLED_CLI_PATH": fakeCLI.path, + "CMUX_CODEX_HOOK_CMUX_BIN": fakeCLI.path, + "CMUX_BUNDLED_CLI_PATH": root.appendingPathComponent("missing-cmux").path, "CMUX_CODEX_PID": "4242", "CMUX_TEST_STDIN": capturedStdin.path, "CMUX_TEST_ARGS": capturedArgs.path, @@ -124,7 +126,11 @@ struct CLICodexHookTimeoutRegressionTests { #expect(run.status == 0, Comment(rawValue: run.stderr)) #expect(run.stdout == "{}\n") #expect(waitForFile(capturedStdin, containing: payload, timeout: 1)) - #expect(waitForFile(capturedArgs, containing: "--socket /tmp/cmux-test.sock hooks codex prompt-submit", timeout: 1)) + #expect(waitForFile( + capturedArgs, + containing: "--socket /tmp/cmux-test.sock hooks codex prompt-submit --workspace workspace-123 --surface surface-123", + timeout: 1 + )) #expect(waitForFile(capturedPID, containing: "4242", timeout: 1)) #expect(waitForFile(doneFile, containing: "done", timeout: 6)) } @@ -861,6 +867,135 @@ struct CLICodexHookTimeoutRegressionTests { #expect(session["terminalPromptTurnIds"] as? [String] == ["turn-done"]) } + @Test func codexPromptSubmitPublishesLatestMessageAsViewportOverlay() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-prompt-overlay-\(UUID().uuidString)", isDirectory: true) + let socketPath = makeCodexHookSocketPath("codex-overlay") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + let commands = CodexHookCapturedSocketCommands() + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + startCodexHookMockSocketServerAccepting( + listenerFD: listenerFD, + commands: commands, + surfaceId: surfaceId, + connectionLimit: 16 + ) + + let result = runCodexHookProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "prompt-submit"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CODEX_PID": "1", + ], + standardInput: #"{"session_id":"codex-overlay-session","turn_id":"turn-1","cwd":"\#(root.path)","hook_event_name":"UserPromptSubmit","prompt":"show this at the top"}"#, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + #expect(result.stdout == "{}\n") + let request = try #require(commands.snapshot().compactMap(codexHookJSONObject).first { + $0["method"] as? String == "surface.overlay.set" + }) + let params = try #require(request["params"] as? [String: Any]) + #expect(params["workspace_id"] as? String == workspaceId) + #expect(params["surface_id"] as? String == surfaceId) + #expect(params["overlay_id"] as? String == "agent.codex.latest-user-message") + #expect(params["text"] as? String == "show this at the top") + #expect(params["anchor"] as? String == "viewport") + #expect(params["position"] as? String == "left") + } + + @Test func codexFastCompletionStillPublishesLatestMessageOverlay() throws { + let cliPath = try bundledCLIPath() + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-codex-fast-overlay-\(UUID().uuidString)", isDirectory: true) + let stateURL = root.appendingPathComponent("codex-hook-sessions.json") + let socketPath = makeCodexHookSocketPath("codex-fast-overlay") + let listenerFD = try bindCodexHookUnixSocket(at: socketPath) + let workspaceId = "11111111-1111-1111-1111-111111111111" + let surfaceId = "22222222-2222-2222-2222-222222222222" + let sessionId = "codex-fast-overlay-session" + let turnId = "turn-fast" + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let commands = CodexHookCapturedSocketCommands { command in + guard codexHookJSONObject(command)?["method"] as? String == "feed.push", + let data = try? Data(contentsOf: stateURL), + var state = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + var sessions = state["sessions"] as? [String: Any], + var session = sessions[sessionId] as? [String: Any] else { + return + } + session["terminalPromptTurnIds"] = [turnId] + sessions[sessionId] = session + state["sessions"] = sessions + if let updated = try? JSONSerialization.data( + withJSONObject: state, + options: [.prettyPrinted, .sortedKeys] + ) { + try? updated.write(to: stateURL, options: .atomic) + } + } + defer { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + + startCodexHookMockSocketServerAccepting( + listenerFD: listenerFD, + commands: commands, + surfaceId: surfaceId, + connectionLimit: 16 + ) + + let result = runCodexHookProcess( + executablePath: cliPath, + arguments: ["hooks", "codex", "prompt-submit"], + environment: [ + "HOME": root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": root.path, + "CMUX_SOCKET_PATH": socketPath, + "CMUX_WORKSPACE_ID": workspaceId, + "CMUX_SURFACE_ID": surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + "CMUX_CODEX_PID": "1", + ], + standardInput: #"{"session_id":"\#(sessionId)","turn_id":"\#(turnId)","cwd":"\#(root.path)","hook_event_name":"UserPromptSubmit","prompt":"fast answer"}"#, + timeout: 5 + ) + + #expect(!result.timedOut, Comment(rawValue: result.stderr)) + #expect(result.status == 0, Comment(rawValue: result.stderr)) + let requests = commands.snapshot().compactMap(codexHookJSONObject) + let overlayRequest = try #require(requests.first { + $0["method"] as? String == "surface.overlay.set" + }) + let params = try #require(overlayRequest["params"] as? [String: Any]) + #expect(params["text"] as? String == "fast answer") + #expect(params["anchor"] as? String == "viewport") + #expect(!commands.snapshot().contains { $0.hasPrefix("set_status codex Running ") }) + } + private func bundledCLIPath() throws -> String { try BundledCLITestSupport.bundledCLIPath(for: BundledCLILinkageTests.self) } diff --git a/cmuxUITests/AutomationSocketUITests.swift b/cmuxUITests/AutomationSocketUITests.swift index 07c68f718c3b..c6e58e0bb225 100644 --- a/cmuxUITests/AutomationSocketUITests.swift +++ b/cmuxUITests/AutomationSocketUITests.swift @@ -148,6 +148,179 @@ final class AutomationSocketUITests: XCTestCase { app.terminate() } + func testTerminalOverlaySocketRendersUpdatesAndRemovesPassiveCard() throws { + let workingDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-ui-terminal-overlay-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory( + at: workingDirectory, + withIntermediateDirectories: true + ) + temporaryRoots.append(workingDirectory) + + let app = XCUIApplication.cmuxTestApplication() + app.launchArguments += [ + "-\(modeKey)", "allowAll", + "-AppleLanguages", "(en)", + "-AppleLocale", "en_US", + "-NSAppSleepDisabled", "YES", + ] + app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + app.launchEnvironment["CMUX_SOCKET_ENABLE"] = "1" + app.launchEnvironment["CMUX_SOCKET_MODE"] = "allowAll" + app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath + app.launchEnvironment["CMUX_ALLOW_SOCKET_OVERRIDE"] = "1" + app.launchEnvironment["CMUX_UI_TEST_SOCKET_SANITY"] = "1" + app.launchEnvironment["CMUX_UI_TEST_DIAGNOSTICS_PATH"] = diagnosticsPath + app.launchEnvironment["CMUX_TAG"] = launchTag + defer { app.terminate() } + let activationOptions = XCTExpectedFailure.Options() + activationOptions.isStrict = false + XCTExpectFailure( + "App activation may fail on headless CI runners", + options: activationOptions + ) { + app.launch() + } + + XCTAssertTrue( + ensureRunningAfterLaunch(app, timeout: 12.0), + "Expected app to launch for terminal overlay test. state=\(app.state.rawValue)" + ) + XCTAssertTrue( + waitForSocketPong(timeout: 12.0), + "Expected socket ping at \(socketPath). diagnostics=\(loadDiagnostics())" + ) + + let workspace = try XCTUnwrap( + socketResult( + method: "workspace.create", + params: [ + "title": "Terminal overlay XCUITest", + "working_directory": workingDirectory.path, + "focus": true, + ] + ), + "Expected workspace.create to succeed" + ) + let surfaceID = try XCTUnwrap( + workspace["surface_id"] as? String, + "Expected workspace.create to return a terminal surface" + ) + + let overlayID = "xcuitest.latest-message" + let initialText = "Latest user message: check the auth error" + let initial = try XCTUnwrap( + socketResult( + method: "surface.overlay.set", + params: [ + "surface_id": surfaceID, + "overlay_id": overlayID, + "text": initialText, + "anchor": "sticky", + "position": "left", + ] + ), + "Expected surface.overlay.set to succeed" + ) + XCTAssertEqual( + (initial["overlay"] as? [String: Any])?["id"] as? String, + overlayID + ) + + let card = app.staticTexts["terminal-overlay-\(overlayID)"] + XCTAssertTrue( + card.waitForExistence(timeout: 8.0), + "Expected the passive terminal overlay card to render" + ) + XCTAssertEqual(card.label, initialText) + + let secondOverlayID = "xcuitest.build-status" + let secondText = "Build passed" + _ = try XCTUnwrap( + socketResult( + method: "surface.overlay.set", + params: [ + "surface_id": surfaceID, + "overlay_id": secondOverlayID, + "text": secondText, + "anchor": "viewport", + "position": "right", + ] + ), + "Expected a second keyed terminal overlay to render" + ) + let secondLine = app.staticTexts["terminal-overlay-\(secondOverlayID)"] + XCTAssertTrue( + secondLine.waitForExistence(timeout: 8.0), + "Expected both terminal overlay lines to coexist" + ) + XCTAssertEqual(secondLine.label, secondText) + XCTAssertEqual(card.frame.height, secondLine.frame.height, accuracy: 1.0) + XCTAssertLessThanOrEqual(card.frame.height, 32.0, "Each overlay should occupy one terminal row") + XCTAssertEqual( + abs(card.frame.minY - secondLine.frame.minY), + card.frame.height, + accuracy: 2.0, + "Multiple top overlays should stack on adjacent terminal rows" + ) + + let updatedText = "Latest user message: rerun the focused test" + _ = try XCTUnwrap( + socketResult( + method: "surface.overlay.set", + params: [ + "surface_id": surfaceID, + "overlay_id": overlayID, + "text": updatedText, + "anchor": "viewport", + "position": "right", + ] + ), + "Expected keyed surface.overlay.set update to succeed" + ) + let updated = expectation( + for: NSPredicate(format: "label == %@", updatedText), + evaluatedWith: card + ) + wait(for: [updated], timeout: 8.0) + + let listed = try XCTUnwrap( + socketResult( + method: "surface.overlay.list", + params: ["surface_id": surfaceID] + ), + "Expected surface.overlay.list to succeed" + ) + let overlays = listed["overlays"] as? [[String: Any]] ?? [] + XCTAssertEqual(overlays.count, 2, "Multiple keys coexist and a keyed update replaces in place") + let updatedOverlay = overlays.first { $0["id"] as? String == overlayID } + XCTAssertEqual(updatedOverlay?["position"] as? String, "right") + XCTAssertEqual(updatedOverlay?["anchor"] as? String, "viewport") + + let removed = try XCTUnwrap( + socketResult( + method: "surface.overlay.remove", + params: ["surface_id": surfaceID, "overlay_id": overlayID] + ), + "Expected surface.overlay.remove to succeed" + ) + XCTAssertEqual(removed["removed"] as? Bool, true) + let disappeared = expectation( + for: NSPredicate(format: "exists == false"), + evaluatedWith: card + ) + wait(for: [disappeared], timeout: 8.0) + XCTAssertTrue(secondLine.exists, "Removing one key must preserve the other overlay") + + _ = try XCTUnwrap( + socketResult( + method: "surface.overlay.clear", + params: ["surface_id": surfaceID] + ), + "Expected surface.overlay.clear to remove remaining lines" + ) + } + func testTextBoxSkillMentionFiltersWhenTypingAfterBareDollarTrigger() throws { let skillRoot = try makeSkillFixtureRoot( skillNames: [