Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ public final class MobileCoreRPCClient: MobileSyncing, Sendable {
case "mobile.terminal.create", "terminal.create":
return false
case "mobile.terminal.input", "terminal.input",
"mobile.terminal.paste", "terminal.paste",
"mobile.terminal.paste_image", "terminal.paste_image",
"mobile.terminal.replay", "terminal.replay",
"mobile.terminal.viewport", "terminal.viewport":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {

private static let terminalRenderGridCapability = "terminal.render_grid.v1"
private static let workspaceActionsCapability = "workspace.actions.v1"
private static let terminalPasteCapability = "terminal.paste.v1"
private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000

/// How long the render-grid stream may stay silent (no event of any topic)
Expand Down Expand Up @@ -170,6 +171,12 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
/// for older Macs that lack the handler, so the UI can hide rename/pin rather
/// than offer actions that would fail with `method_not_found`.
public private(set) var supportsWorkspaceActions: Bool = false
/// Whether the connected Mac advertises the `terminal.paste.v1` capability
/// (the bracketed-paste `terminal.paste` RPC). `false` until host status is
/// read, and for older Macs that lack the handler, so multi-character commits
/// (dictation, autocorrect, keyboard/clipboard paste) fall back to per-key
/// `terminal.input` instead of being dropped with `method_not_found`.
public private(set) var supportsTerminalPaste: Bool = false
public var terminalInputText: String
public var selectedWorkspaceID: MobileWorkspacePreview.ID? {
didSet {
Expand Down Expand Up @@ -1427,6 +1434,43 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
await sendRemoteTerminalInput(text, workspaceID: workspaceID, terminalID: terminalID)
}

/// Send a committed block of text (system dictation, an autocorrect
/// replacement, or keyboard-inserted clipboard text) to the terminal that
/// owns `surfaceID` as a *bracketed paste*.
///
/// Unlike ``submitTerminalRawInput(_:surfaceID:)``, this routes to the
/// Mac's `terminal.paste` RPC, which delivers the text through Ghostty's
/// paste path (`ghostty_surface_text`). That keeps embedded newlines part of
/// one paste so a running shell or TUI does not execute each line as a
/// separate command, and lets bracketed-paste-aware programs treat it as
/// pasted content.
/// - Parameters:
/// - text: The committed block. Sent verbatim; the Mac applies bracketed
/// paste framing.
/// - surfaceID: The terminal surface id the block targets.
public func submitTerminalPasteText(_ text: String, surfaceID: String) async {
guard !text.isEmpty else { return }
let workspaceCandidate = workspaces.first(where: { workspace in
workspace.terminals.contains(where: { $0.id.rawValue == surfaceID })
})
guard let workspace = workspaceCandidate else { return }
guard remoteClient != nil else { return }
let terminalID = MobileTerminalPreview.ID(rawValue: surfaceID)
// Fall back to per-key input when the paired Mac is too old to advertise
// the bracketed-paste RPC, so a new client + old host drops nothing.
// `terminal.input` expects CR for Return, so normalize newlines.
guard supportsTerminalPaste else {
let normalized = text.replacingOccurrences(of: "\n", with: "\r")
await submitTerminalRawInput(normalized, workspaceID: workspace.id, terminalID: terminalID)
Comment on lines +1462 to +1464

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize CRLF before CR fallback conversion.

Line 1463 replaces \n with \r directly, so Windows-style \r\n becomes \r\r on old-host fallback and can inject extra Returns.

Suggested fix
-            let normalized = text.replacingOccurrences(of: "\n", with: "\r")
+            let normalized = text
+                .replacingOccurrences(of: "\r\n", with: "\n")
+                .replacingOccurrences(of: "\r", with: "\n")
+                .replacingOccurrences(of: "\n", with: "\r")
             await submitTerminalRawInput(normalized, workspaceID: workspace.id, terminalID: terminalID)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`
around lines 1462 - 1464, The current fallback branch in MobileShellComposite
(guard supportsTerminalPaste -> submitTerminalRawInput) naively replaces "\n"
with "\r" causing Windows CRLF ("\r\n") to become "\r\r"; first normalize CRLF
by replacing occurrences of "\r\n" with a single "\r", then replace any
remaining "\n" with "\r" before calling submitTerminalRawInput (refer to
symbols: supportsTerminalPaste, text, normalized, submitTerminalRawInput,
workspace.id, terminalID).

return
}
await sendRemoteTerminalPasteText(
text,
workspaceID: workspace.id,
terminalID: terminalID
)
}

private func drainRawTerminalInputBuffer() async {
while let chunk = rawTerminalInputBuffer.nextBatch() {
await submitTerminalRawInput(
Expand Down Expand Up @@ -1704,6 +1748,9 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalReplaySurfaceIDsInFlight = []
terminalOutputTransport = .rawBytes
supportsWorkspaceActions = false
// Clear paste support too, so a reconnect to an older host cannot send
// `terminal.paste` on a stale `true` before the next status probe lands.
supportsTerminalPaste = false
terminalSubscriptionRefreshTask?.cancel()
terminalSubscriptionRefreshTask = nil
stopRenderGridLivenessWatchdog(listenerID: nil)
Expand Down Expand Up @@ -2054,6 +2101,44 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
}
}

private func sendRemoteTerminalPasteText(
_ text: String,
workspaceID: MobileWorkspacePreview.ID,
terminalID: MobileTerminalPreview.ID
) async {
guard let client = remoteClient else { return }
let generation = connectionGeneration
do {
#if DEBUG
mobileShellLog.debug("send remote terminal paste text byteCount=\(text.utf8.count, privacy: .public) workspace=\(workspaceID.rawValue, privacy: .private) terminal=\(terminalID.rawValue, privacy: .private)")
#endif
let key = viewportKey(workspaceID: workspaceID, terminalID: terminalID)
var params: [String: Any] = [
"workspace_id": workspaceID.rawValue,
"surface_id": terminalID.rawValue,
"text": text,
"client_id": clientID,
]
if let viewportSize = reportedViewportSizesByTerminalKey[key] {
params["viewport_columns"] = viewportSize.columns
params["viewport_rows"] = viewportSize.rows
}
let responseData = try await client.sendRequest(
MobileCoreRPCClient.requestData(
method: "terminal.paste",
params: params
)
)
guard isCurrentRemoteOperation(client: client, generation: generation) else { return }
handleTerminalInputResponse(responseData, surfaceID: terminalID.rawValue)
} catch {
guard generation == connectionGeneration else { return }
guard !disconnectForAuthorizationFailureIfNeeded(error) else { return }
markMacConnectionUnavailableIfNeeded(after: error)
connectionError = Self.localizedConnectionError(for: error)
}
}

private var terminalEventStreamID: String {
"ios-terminal-events-\(clientID)"
}
Expand Down Expand Up @@ -2111,9 +2196,11 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
guard let payload = try? MobileHostStatusResponse.decode(data) else {
terminalOutputTransport = fallback
supportsWorkspaceActions = false
supportsTerminalPaste = false
return fallback
}
supportsWorkspaceActions = payload.capabilities.contains(Self.workspaceActionsCapability)
supportsTerminalPaste = payload.capabilities.contains(Self.terminalPasteCapability)
let transport: TerminalOutputTransport = payload.capabilities.contains(Self.terminalRenderGridCapability) ||
payload.terminalFidelity == "render_grid" ? .renderGrid : .rawBytes
terminalOutputTransport = transport
Expand All @@ -2122,6 +2209,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
} catch {
terminalOutputTransport = fallback
supportsWorkspaceActions = false
supportsTerminalPaste = false
MobileDebugLog.anchormux("sync.transport=raw_bytes reason=status_failed")
return fallback
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,15 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable {
}
}

func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteText text: String) {
// A committed block of text (dictation, autocorrect, keyboard
// clipboard insert). Send it through the Mac's bracketed-paste RPC so
// newlines stay part of one paste instead of fragmenting into Returns.
Task { @MainActor [weak store] in
await store?.submitTerminalPasteText(text, surfaceID: self.surfaceID)
}
}

func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didResize size: TerminalGridSize) {
// Report our natural grid to the Mac and pin our render to the
// effective grid it returns (the smallest across every attached
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,33 @@ public protocol GhosttySurfaceViewDelegate: AnyObject {
/// path into the terminal so a running TUI (e.g. Claude Code) attaches it.
/// `format` is a lowercase file-extension hint (e.g. `"png"`). Optional.
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteImage data: Data, format: String)
/// Forward a committed block of text (system dictation, an autocorrect
/// replacement, or keyboard-inserted clipboard text) that should reach the
/// remote terminal as a bracketed paste rather than per-character input. The
/// host sends it via the `terminal.paste` RPC so embedded newlines do not
/// fragment into separate Returns. Defaults to the raw-input path so existing
/// conformers keep working. Optional.
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteText text: String)
}

public extension GhosttySurfaceViewDelegate {
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didScrollLines lines: Double, atCol col: Int, row: Int) {}
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) {}
func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) {}
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteImage data: Data, format: String) {}
/// Default bracketed-paste handler that falls back to per-character input.
///
/// A conformer that does not implement the bracketed-paste path still
/// delivers the text: newlines are normalized to CR (matching the
/// per-keystroke input path) and the bytes are forwarded through
/// ``ghosttySurfaceView(_:didProduceInput:)``.
/// - Parameters:
/// - surfaceView: The surface view that produced the committed block.
/// - text: The committed block of pasted/dictated text.
func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteText text: String) {
let normalized = text.replacingOccurrences(of: "\n", with: "\r")
ghosttySurfaceView(surfaceView, didProduceInput: Data(normalized.utf8))
}
}

@MainActor
Expand Down Expand Up @@ -805,6 +825,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting {
TerminalInputDebugLog.log("surface.onEscape data=\(TerminalInputDebugLog.dataSummary(data))")
self.delegate?.ghosttySurfaceView(self, didProduceInput: data)
}
inputProxy.onPasteText = { [weak self] text in
guard let self else { return }
self.resetCursorBlink()
// Multi-character commits (dictation, autocorrect, keyboard clipboard
// insert) go through the bracketed-paste RPC so embedded newlines are
// not split into separate Returns by the remote terminal.
TerminalInputDebugLog.log("surface.onPasteText text=\(TerminalInputDebugLog.textSummary(text))")
self.delegate?.ghosttySurfaceView(self, didPasteText: text)
}
inputProxy.onPasteImage = { [weak self] data, format in
guard let self else { return }
TerminalInputDebugLog.log("surface.onPasteImage bytes=\(data.count) format=\(format)")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import UIKit

/// An opaque text position for the terminal input view's hand-rolled
/// ``UITextInput`` conformance.
///
/// ``TerminalInputTextView`` is a remote-terminal proxy: it owns no editable
/// document, so it never exposes real character offsets. UIKit's text-input
/// machinery (IME composition, the dictation placeholder, the "speak selection"
/// action) still requires the view to vend `UITextPosition`/`UITextRange`
/// instances, so this is a sentinel with no addressable offset. It exists only
/// to satisfy the protocol's identity requirements; the geometry/offset methods
/// that would consume it all return neutral values.
final class TerminalInputTextPosition: UITextPosition {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import UIKit

/// An opaque text range for the terminal input view's hand-rolled
/// ``UITextInput`` conformance.
///
/// Like ``TerminalInputTextPosition``, this carries no real document offsets.
/// ``TerminalInputTextView`` keeps two long-lived range sentinels — one
/// identifying the IME marked-text region, one identifying the (always empty)
/// selection — and returns the matching sentinel from `markedTextRange` /
/// `selectedTextRange`. UIKit compares ranges by object identity here, so the
/// view can answer `textInRange:` by checking which sentinel it was handed
/// rather than by indexing a buffer it does not keep.
final class TerminalInputTextRange: UITextRange {
private let position = TerminalInputTextPosition()

/// The start of the range. Both ends return the same sentinel position
/// because the range addresses no real document span; callers only use it
/// for identity comparison, never to compute offsets.
override var start: UITextPosition { position }

/// The end of the range. Returns the same sentinel as ``start`` (see the
/// note there): the range has no measurable length.
override var end: UITextPosition { position }

/// Always reports empty. The view never holds a non-empty selection, and the
/// marked-text contents are tracked out of band by the view, not via a
/// measurable range here.
override var isEmpty: Bool { true }
}
Loading