Skip to content

iOS: paste images into the terminal from the phone clipboard - #5546

Merged
lawrencecchen merged 1 commit into
mainfrom
feat-ios-image-paste
Jun 7, 2026
Merged

lawrencecchen merged 1 commit into
mainfrom
feat-ios-image-paste

Conversation

@lawrencecchen

@lawrencecchen lawrencecchen commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Adds a Paste button to the iOS terminal accessory bar so you can paste an image (or text) from the phone clipboard straight into the terminal. Requested alongside the composer work; this is the image-paste slice.

Behavior

  • Tap Paste. If the clipboard holds an image, it is forwarded to the paired Mac over a new terminal.paste_image RPC (base64 bytes + a format hint; PNG, JPEG fallback above 8 MB). The Mac writes it to a temp file (reusing the existing owned-temp-image machinery) and injects the shell-escaped path as terminal input, exactly the way a local clipboard-image paste does, so a running TUI like Claude Code attaches it.
  • If the clipboard holds text, it rides the normal input path (no RPC).

Touch points

  • Mac: GhosttyPasteboardHelper.saveImageData(_:fileExtension:); v2MobileTerminalPasteImage handler + dispatch case in TerminalController.mobileHostHandleRPC; terminal.paste_image added to the client token-selection group (MobileCoreRPCClient) and the server ticket-authorization group (MobileHostService) so it is scoped exactly like terminal.input. executionPolicy already defaults it to the main actor.
  • iOS: pinned .paste accessory action; TerminalInputTextView.handlePasteAction reads UIPasteboard; onPasteImage callback chains GhosttySurfaceView → delegate → MobileShellComposite.submitTerminalPasteImage.
  • Localized Paste label (en + ja).

Notes / coordination

Test

Needs a real device + paired Mac (mobile-host RPC round trip), so verified by dogfood: copy an image on the phone, tap Paste in a Claude Code terminal, confirm [Image #N] attaches. A unit test of the byte→tempfile→path Mac helper is a reasonable follow-up; the cross-process RPC path is exercised end-to-end by the dogfood.

🤖 Generated with Claude Code


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.


Note

Medium Risk
New RPC carries large base64 payloads and writes temp files before injecting paths into the PTY; auth is aligned with terminal.input but size limits and extension sanitization are the main guardrails.

Overview
Adds Paste on the iOS terminal accessory bar so clipboard content on the phone can reach the Mac-hosted terminal. Images are sent over a new terminal.paste_image RPC as base64 plus a format hint; the Mac writes a temp file and injects a shell-escaped path as input (same behavior as a local clipboard-image paste for TUIs like Claude Code). Text still uses the existing terminal input path—no new RPC.

On iOS, handlePasteAction reads UIPasteboard (images preferred over text, JPEG fallback when PNG exceeds ~8 MB). submitTerminalPasteImage in the shell composite issues the RPC with the same attach-ticket scoping as terminal.input. On Mac, GhosttyPasteboardHelper.saveImageData, v2MobileTerminalPasteImage, and auth grouping in MobileCoreRPCClient / MobileHostService wire the handler. Localized Paste strings (en/ja) are included; unrelated TestFlight strings were removed from Localizable.xcstrings in the same diff.

Reviewed by Cursor Bugbot for commit 3776970. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Add a Paste button to the iOS terminal accessory bar so you can paste images or text from the phone clipboard into the Mac-hosted terminal. Images go over a new terminal.paste_image RPC and appear as file paths so TUIs (e.g., Claude Code) can attach them.

  • New Features
    • iOS: pinned .paste accessory button reads UIPasteboard; images prefer PNG, fall back to JPEG over 8 MB; text uses the normal input path.
    • Wiring: onPasteImage flows TerminalInputTextView → GhosttySurfaceView → delegate → MobileShellComposite.submitTerminalPasteImage.
    • RPC/Host: new terminal.paste_image added to MobileCoreRPCClient and MobileHostService auth groups (scoped like terminal.input); TerminalController.v2MobileTerminalPasteImage decodes base64, uses GhosttyPasteboardHelper.saveImageData(_:fileExtension:) to create a temp file, then injects the shell-escaped path as input.
    • Limits/Safety: 10 MB cap on image payloads; sanitize file-extension hint; reuses owned-temp-image cleanup paths.
    • UI: Localized “Paste” label (en, ja) with doc.on.clipboard icon.

Written for commit 3776970. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features
    • Paste images from the clipboard directly into remote terminals via a new Paste action in the terminal input UI (PNG/JPEG supported, size limits and retries handled).
  • Reliability
    • Improved handling of image-send failures with clearer connection/error feedback and retry/queue behavior so paste operations are less likely to be lost.
  • Localization
    • Added localized label for the Paste action.

@vercel

vercel Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Jun 7, 2026 1:57am
cmux-staging Building Building Preview, Comment Jun 7, 2026 1:57am

@coderabbitai

coderabbitai Bot commented Jun 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds end-to-end clipboard-image paste support: new Paste accessory reads clipboard images, the app sends a base64 RPC (terminal.paste_image), the host saves bytes to a temp file, and the terminal injects the escaped file path into the input stream.

Changes

Terminal Image Paste Support

Layer / File(s) Summary
Paste Accessory UI and Clipboard Integration
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift, Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift, ios/cmux/Resources/Localizable.xcstrings
New GhosttySurfaceViewDelegate.ghosttySurfaceView(_:didPasteImage:format:) callback and default no-op. Added TerminalInputAccessoryAction.paste with UI/accessibility mapping and symbol. TerminalInputTextView exposes onPasteImage, includes .paste in pinned accessories, and implements handlePasteAction() to read UIPasteboard.general (prefer PNG ≤8 MB, JPEG fallback, PNG final fallback) and forward bytes or text. Localized "Paste" / "ペースト" added.
Mobile Shell Paste Request Bridge
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift, Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift, Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift
Coordinator handler forwards pasted image to submitTerminalPasteImage(data:format:). MobileShellComposite validates selection, base64-encodes image, sends terminal.paste_image RPC with workspace_id, surface_id, image_base64, image_format, and client_id, and reuses handleTerminalInputResponse path; on error it checks generation/authorization and sets localized connection error. MobileCoreRPCClient excludes mobile.terminal.paste_image/terminal.paste_image from Stack-auth fallback.
Server Image Persistence and Terminal Injection
Sources/Mobile/MobileHostService.swift, Sources/TerminalController.swift, Sources/GhosttyTerminalView.swift
MobileHostService.ticketAuthorizationError(...) exempts paste RPCs from ticket-scoped auth error. TerminalController dispatches mobile.terminal.paste_image to v2MobileTerminalPasteImage, decodes image_base64, defaults image_format to png, resolves terminal surface, calls GhosttyPasteboardHelper.saveImageData() to write a sanitized temp file (≤10 MB), injects escaped path with sendInputResult (force-refresh on immediate send), maps send outcomes to structured errors, and returns workspace_id, surface_id, and queued flag.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant TerminalInputTextView as TerminalInputTextView
  participant UIPasteboard
  participant GhosttySurfaceView
  participant Coordinator
  participant MobileShellComposite
  participant RPC as RPC Client
  participant TerminalController
  participant GhosttyPasteboardHelper
  participant TerminalSurface
  
  User->>TerminalInputTextView: tap paste accessory
  TerminalInputTextView->>UIPasteboard: read clipboard
  alt Image available
    TerminalInputTextView->>GhosttySurfaceView: onPasteImage(data, format)
    GhosttySurfaceView->>Coordinator: ghosttySurfaceView(_:didPasteImage:format:)
    Coordinator->>MobileShellComposite: submitTerminalPasteImage(data, format)
    MobileShellComposite->>RPC: terminal.paste_image (base64-encoded)
    RPC->>TerminalController: v2MobileTerminalPasteImage request
    TerminalController->>GhosttyPasteboardHelper: saveImageData(decoded bytes, format)
    GhosttyPasteboardHelper->>GhosttyPasteboardHelper: write temp file
    TerminalController->>TerminalSurface: sendInputResult(escaped path)
  else Text only
    TerminalInputTextView->>GhosttySurfaceView: onText(string)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • manaflow-ai/cmux#5532: Touches terminal input accessory ordering and defaults which interact with the new .paste accessory placement and visibility.

Poem

🐰 I nibble bytes from clipboard land,
I base64-hop and lend a hand,
From phone to host the pixels glide,
A temp-file path becomes their ride,
The rabbit cheers — paste now trips the line.


Important

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

❌ Failed checks (4 errors, 1 warning)

Check name Status Explanation Resolution
Cmux Algorithmic Complexity ❌ Error submitTerminalPasteImage reads global selectedTerminal instead of surfaceID; Base64 decoded before size validation causes memory spikes on oversized payloads. Use surfaceID param like submitTerminalRawInput does; validate base64 size before Data(base64Encoded:) to avoid pre-allocation memory spikes.
Cmux Swift Concurrency ❌ Error Introduces fire-and-forget Task in didPasteImage delegate with meaningful async lifecycle not stored or cancelled, violating swift-concurrency-modernization rules. Store Task in property and manage lifecycle like outputTask, or track delegate callback Tasks centrally for cancellation.
Cmux Swift @Concurrent ❌ Error sendRemoteTerminalPasteImage does CPU-heavy base64 encoding and network I/O on @MainActor; v2MobileTerminalPasteImage does file I/O on @MainActor without explicit hops or @concurrent. Mark sendRemoteTerminalPasteImage @concurrent with detach before network call; detach from MainActor before file I/O in v2MobileTerminalPasteImage.
Cmux Architecture Rethink ❌ Error submitTerminalPasteImage reads selectedTerminalID at dispatch time instead of capturing surfaceID at event-time like submitTerminalRawInput, risking image injection into wrong terminal. Pass surfaceID parameter to submitTerminalPasteImage matching submitTerminalRawInput pattern; call with self.surfaceID from didPasteImage to capture at event-time not dispatch-time.
Docstring Coverage ⚠️ Warning Docstring coverage is 32.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (14 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature being added: a Paste button for images from the iOS phone clipboard into the terminal.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Cmux Swift Actor Isolation ✅ Passed New didPasteImage delegate follows existing @MainActor pattern; submitTerminalPasteImage already @MainActor; static helpers isolated; no implicit MainActor values or mutable Sendable introduced.
Cmux Swift Blocking Runtime ✅ Passed PR introduces no blocking/timing-based synchronization; all new methods use async/await, file I/O, or callbacks without semaphores, locks, sleeps, or manual sync constructs.
Cmux No Hacky Sleeps ✅ Passed Check does not apply: all PR changes are in Swift and XML files; policy explicitly exempts Swift timing code.
Cmux Swift File And Package Boundaries ✅ Passed Adds 216 lines to oversized files (< 250 threshold), no new responsibility mixing, follows existing patterns, qualifies as allowed UI glue and focused handlers.
Cmux Swift Logging ✅ Passed All logging additions in the PR comply with swift-logging.md rules: NSLog and FileHandle logging are properly guarded by #if DEBUG (allowed case); new paste image methods contain no logging.
Cmux User-Facing Error Privacy ✅ Passed All new error messages are generic and product-level; no vendor names, credentials, environment variables, or other forbidden content exposed.
Cmux Full Internationalization ✅ Passed New user-facing string "Paste" uses String(localized:) with matching Localizable.xcstrings entry translated to both supported locales (en, ja); no unlocalized strings or web UI copy found.
Cmux Swiftui State Layout ✅ Passed PR adds image paste feature using @Observable (modern shape), UIViewRepresentable bridge layers, and UIKit delegate callbacks—no violations of swiftui-state-layout rules detected.
Cmux Swift Auxiliary Window Close Shortcuts ✅ Passed PR adds iOS clipboard paste via RPC without creating or materially changing standalone windows; changes are delegate methods, accessory actions, and RPC handlers only.
Cmux Source Artifacts ✅ Passed All 9 changed files are legitimate hand-written source files, configs, or localization catalogs. No build artifacts, caches, generated logs, temp folders, or hidden scratch directories detected.
Description check ✅ Passed The PR description is comprehensive and follows the template structure, covering Summary, Behavior, Testing, and Checklist sections with clear details about what changed and why.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat-ios-image-paste

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

"workspace_id": resolved.workspace.id.uuidString,
"surface_id": terminalPanel.id.uuidString,
"queued": sendResult == .queued,
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Missing terminal sequence in response

Medium Severity

The terminal.paste_image success payload omits terminal_seq, while the iOS client still runs handleTerminalInputResponse for that RPC. Without terminal_seq, the render-grid sync path never compares remote vs local byte sequences after an image paste, so the phone can stay behind when the Mac terminal updates heavily.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5f6e1ae. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f6e1ae78c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +545 to +547
let maxImageBytes = 8 * 1024 * 1024
if let png = image.pngData(), png.count <= maxImageBytes {
onPasteImage?(png, "png")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cap pasted images before base64 expansion

When a clipboard image is around 6–8 MiB, this branch forwards the PNG because it is below maxImageBytes, but the request then embeds it as base64 inside JSON before MobileCoreRPCSession calls MobileSyncFrameCodec.encodeFrame, whose payload limit is 8 MiB. Base64 expands the bytes by roughly 4/3 (plus auth/JSON overhead), so these images deterministically throw frameTooLarge on the phone and never reach the Mac despite being under this check; the cap needs to account for encoded frame size or recompress further before sending.

Useful? React with 👍 / 👎.

Comment on lines +21366 to +21370
return .ok([
"workspace_id": resolved.workspace.id.uuidString,
"surface_id": terminalPanel.id.uuidString,
"queued": sendResult == .queued,
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return terminal_seq for paste-image input

sendRemoteTerminalPasteImage feeds this response into handleTerminalInputResponse, but that path only does its catch-up/replay work when the payload contains terminal_seq; unlike v2MobileTerminalInput, this response omits it. For render-grid mobile clients that are behind after the injected path is sent, the paste-image RPC will not trigger the same resync safety net as normal terminal input, so the phone can keep showing stale terminal contents until another event or manual replay occurs.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jun 6, 2026 •

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds iOS clipboard image paste to the terminal: a pinned Paste button reads UIPasteboard, sends images over a new terminal.paste_image RPC (base64 + format hint), and on the Mac writes a sanitized temp file and injects the shell-escaped path as terminal input, mirroring the existing local clipboard-image paste path.

  • iOS side: TerminalInputTextView.handlePasteAction reads the clipboard, prefers PNG ≤ 8 MB, falls back to JPEG, then forwards bytes + format hint through GhosttySurfaceView → GhosttySurfaceRepresentable → MobileShellComposite.submitTerminalPasteImage; localized en/ja strings cover all supported locales.
  • Mac side: GhosttyPasteboardHelper.saveImageData sanitizes the file extension, enforces the 10 MB cap, writes the temp file, and registers it with the existing owned-temp-file tracker; v2MobileTerminalPasteImage decodes, validates, writes, and injects the path using the same sendInputResult path as terminal.input.
  • Auth/scoping: terminal.paste_image is added to both the client token-selection group and the server ticket-authorization group, exactly matching terminal.input scoping.

Confidence Score: 4/5

Functional but the paste-image path skips the accelerated terminal resync that text input uses, making the iOS display lag after paste.

The terminal.paste_image response omits terminal_seq, so handleTerminalInputResponse hits its early-return guard and the post-input resync that normally keeps the iOS terminal display current after injecting input never fires. Terminal output from the TUI will arrive via background subscription polling rather than the immediate resync, producing noticeable display lag after every image paste.

Sources/TerminalController.swift — v2MobileTerminalPasteImage response should include terminal_seq via MobileTerminalByteTee, matching the v2MobileTerminalInput pattern.

Important Files Changed

Filename Overview
Sources/TerminalController.swift Adds v2MobileTerminalPasteImage RPC handler; response is missing terminal_seq so the post-input resync is silently skipped on the iOS client
Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift Adds submitTerminalPasteImage and sendRemoteTerminalPasteImage; reuses handleTerminalInputResponse correctly but that path silently no-ops because terminal_seq is absent from the paste_image response
Sources/GhosttyTerminalView.swift Adds saveImageData helper; correctly sanitizes file extension, enforces 10 MB cap, and uses existing owned-temp-file tracking with proper lock discipline
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift Adds Paste button and handlePasteAction; JPEG fallback path does not verify payload stays under the Mac's 10 MB cap before forwarding (already flagged in previous thread)
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift Adds paste action enum case and onPasteImage delegate wiring; follows existing patterns correctly
Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift Coordinator delegate wiring for didPasteImage; correctly wraps the call in Task @mainactor to match store isolation
Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift Adds paste_image to the terminal-ticket authorization group, exactly mirroring terminal.input scoping
Sources/Mobile/MobileHostService.swift Adds paste_image to the server-side terminal-ticket authorization group, matching client-side scoping
ios/cmux/Resources/Localizable.xcstrings Adds terminal.input_accessory.paste with en and ja translations covering all supported locales; also removes stale testflight keys

Sequence Diagram

sequenceDiagram
    participant PB as UIPasteboard
    participant TIV as TerminalInputTextView
    participant GSV as GhosttySurfaceView
    participant GSR as GhosttySurfaceRepresentable
    participant MSC as MobileShellComposite
    participant RPC as terminal.paste_image RPC
    participant TC as TerminalController (Mac)
    participant GPH as GhosttyPasteboardHelper
    participant PTY as Terminal PTY

    TIV->>PB: hasImages / image
    PB-->>TIV: "UIImage (<=8MB PNG or JPEG)"
    TIV->>GSV: onPasteImage(data, format)
    GSV->>GSR: delegate.didPasteImage(data, format)
    GSR->>MSC: submitTerminalPasteImage(data, format)
    MSC->>RPC: "terminal.paste_image {workspace_id, surface_id, image_base64, image_format}"
    RPC->>TC: v2MobileTerminalPasteImage(params)
    TC->>GPH: saveImageData(imageData, fileExtension)
    GPH-->>TC: "shell-escaped /tmp/clipboard-*.png path"
    TC->>PTY: sendInputResult(escapedPath)
    PTY-->>TC: .sent / .queued
    TC-->>RPC: "ok {workspace_id, surface_id, queued}"
    RPC-->>MSC: responseData
    MSC->>MSC: handleTerminalInputResponse early-returns: terminal_seq absent
Loading

Reviews (3): Last reviewed commit: "iOS: paste images into the terminal from..." | Re-trigger Greptile

Comment on lines +550 to +557
if let jpeg = image.jpegData(compressionQuality: 0.8) {
onPasteImage?(jpeg, "jpg")
return
}
if let png = image.pngData() {
onPasteImage?(png, "png")
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Silent paste failure for large images

The JPEG fallback (lines 550–553) sends the JPEG to the Mac without checking if it fits within the Mac's 10 MB cap, and the last-resort PNG path (lines 554–557) has no size check at all. When a modern iPhone camera photo produces a JPEG still above 10 MB, the Mac's saveImageData returns nil and sends back an invalid_params error. handleTerminalInputResponse silently discards that response (the guard on terminalSeq returns early), leaving the user with no indication that the paste failed. The code comment says JPEG keeps the payload "under the Mac's 10 MB cap" but the code doesn't enforce this.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with 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.

Inline comments:
In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift`:
- Around line 542-557: handlePasteAction forwards image bytes without
consistently enforcing the 10 MB cap; change maxImageBytes to 10 * 1024 * 1024
and ensure every branch checks data.count <= maxImageBytes before calling
onPasteImage. Specifically, inside handlePasteAction compute image.pngData()
once (reuse variable) and compute jpegData as needed, then for each non-nil data
(png or jpeg) verify data.count <= maxImageBytes and only then call
onPasteImage(data, "png"/"jpg"); if it exceeds the cap, handle (e.g., no-op or
trigger an error path) instead of forwarding oversized bytes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c92ac7f-d8db-4a7b-afcb-6d7669f67bd1

📥 Commits

Reviewing files that changed from the base of the PR and between 515c2b4 and 5f6e1ae.

📒 Files selected for processing (9)
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Mobile/MobileHostService.swift
  • Sources/TerminalController.swift
  • ios/cmux/Resources/Localizable.xcstrings

Comment on lines +542 to +557
private func handlePasteAction() {
let pasteboard = UIPasteboard.general
if pasteboard.hasImages, let image = pasteboard.image {
let maxImageBytes = 8 * 1024 * 1024
if let png = image.pngData(), png.count <= maxImageBytes {
onPasteImage?(png, "png")
return
}
if let jpeg = image.jpegData(compressionQuality: 0.8) {
onPasteImage?(jpeg, "jpg")
return
}
if let png = image.pngData() {
onPasteImage?(png, "png")
return
}

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 | 🟠 Major | ⚡ Quick win

Enforce the 10 MB upload cap before forwarding pasted images.

handlePasteAction() currently forwards JPEG/PNG bytes without a hard 10 MB check (Line 550 and Line 554 paths), so oversized images can still be sent and fail later server-side instead of being handled consistently at the source.

Suggested patch
 private func handlePasteAction() {
     let pasteboard = UIPasteboard.general
     if pasteboard.hasImages, let image = pasteboard.image {
-        let maxImageBytes = 8 * 1024 * 1024
+        let jpegSwitchThreshold = 8 * 1024 * 1024
+        let maxUploadBytes = 10 * 1024 * 1024
         if let png = image.pngData(), png.count <= maxImageBytes {
-            onPasteImage?(png, "png")
+            onPasteImage?(png, "png")
             return
         }
-        if let jpeg = image.jpegData(compressionQuality: 0.8) {
+        if let jpeg = image.jpegData(compressionQuality: 0.8), jpeg.count <= maxUploadBytes {
             onPasteImage?(jpeg, "jpg")
             return
         }
-        if let png = image.pngData() {
+        if let png = image.pngData(), png.count <= maxUploadBytes {
             onPasteImage?(png, "png")
             return
         }
     }
     if pasteboard.hasStrings, let string = pasteboard.string, !string.isEmpty {
         onText?(string)
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private func handlePasteAction() {
let pasteboard = UIPasteboard.general
if pasteboard.hasImages, let image = pasteboard.image {
let maxImageBytes = 8 * 1024 * 1024
if let png = image.pngData(), png.count <= maxImageBytes {
onPasteImage?(png, "png")
return
}
if let jpeg = image.jpegData(compressionQuality: 0.8) {
onPasteImage?(jpeg, "jpg")
return
}
if let png = image.pngData() {
onPasteImage?(png, "png")
return
}
private func handlePasteAction() {
let pasteboard = UIPasteboard.general
if pasteboard.hasImages, let image = pasteboard.image {
let maxUploadBytes = 10 * 1024 * 1024
if let png = image.pngData(), png.count <= maxUploadBytes {
onPasteImage?(png, "png")
return
}
if let jpeg = image.jpegData(compressionQuality: 0.8), jpeg.count <= maxUploadBytes {
onPasteImage?(jpeg, "jpg")
return
}
if let png = image.pngData(), png.count <= maxUploadBytes {
onPasteImage?(png, "png")
return
}
🤖 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/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift`
around lines 542 - 557, handlePasteAction forwards image bytes without
consistently enforcing the 10 MB cap; change maxImageBytes to 10 * 1024 * 1024
and ensure every branch checks data.count <= maxImageBytes before calling
onPasteImage. Specifically, inside handlePasteAction compute image.pngData()
once (reuse variable) and compute jpegData as needed, then for each non-nil data
(png or jpeg) verify data.count <= maxImageBytes and only then call
onPasteImage(data, "png"/"jpg"); if it exceeds the cap, handle (e.g., no-op or
trigger an error path) instead of forwarding oversized bytes.

Comment on lines +542 to +561
private func handlePasteAction() {
let pasteboard = UIPasteboard.general
if pasteboard.hasImages, let image = pasteboard.image {
let maxImageBytes = 8 * 1024 * 1024
if let png = image.pngData(), png.count <= maxImageBytes {
onPasteImage?(png, "png")
return
}
if let jpeg = image.jpegData(compressionQuality: 0.8) {
onPasteImage?(jpeg, "jpg")
return
}
if let png = image.pngData() {
onPasteImage?(png, "png")
return
}
}
if pasteboard.hasStrings, let string = pasteboard.string, !string.isEmpty {
onText?(string)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 No user feedback when image paste is silently dropped

handlePasteAction calls onPasteImage? after reading the clipboard (and triggering the iOS one-shot consent banner), but the entire callback chain — GhosttySurfaceRepresentable → store?.submitTerminalPasteImage — silently returns without any user-visible signal when the Mac remote is unavailable: guard remoteClient != nil else { return }. The same silent drop happens when selectedWorkspace or selectedTerminalID is nil. A user who taps Paste, grants the consent dialog, and sees nothing will have no way to know the paste was discarded. An error toast, HUD, or at minimum a logged message via a user-visible path should be shown in every early-return branch of submitTerminalPasteImage.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Adds a Paste button to the mobile terminal accessory bar. An image on the
system clipboard is forwarded to the paired Mac over a new terminal.paste_image
RPC (base64 bytes + format hint); the Mac writes it to a temp file (reusing the
existing owned-temp-image machinery) and injects the shell-escaped path as
terminal input, exactly the way a local clipboard-image paste does, so a running
TUI like Claude Code attaches the image. Clipboard text rides the normal input
path.

iOS: .paste accessory action (pinned), TerminalInputTextView.handlePasteAction
reads UIPasteboard (PNG, JPEG fallback over 8MB), onPasteImage callback chained
through GhosttySurfaceView -> delegate -> MobileShellComposite.submitTerminalPasteImage.

Mac: v2MobileTerminalPasteImage handler + dispatch; GhosttyPasteboardHelper.
saveImageData(_:fileExtension:); terminal.paste_image added to both the
client token-selection and server ticket-authorization terminal groups so it is
scoped exactly like terminal.input. Localized Paste label (en + ja).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lawrencecchen
lawrencecchen force-pushed the feat-ios-image-paste branch from 5f6e1ae to 3776970 Compare June 7, 2026 01:54

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3776970. Configure here.

// writes a temp file and injects its path into the terminal so the
// running TUI (e.g. Claude Code) attaches it.
Task { @MainActor [weak store] in
await store?.submitTerminalPasteImage(data, format: format)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Paste image ignores surface ID

High Severity

Image paste is sent using selectedWorkspace and selectedTerminalID, while keyboard and text paste from the same surface use surfaceID to resolve the workspace and terminal. After switching workspaces without updating the stored terminal id, the visible surface and RPC target can diverge, so image paste may fail or target the wrong terminal while text paste still works.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3776970. Configure here.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 377697021f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return .err(code: "invalid_params", message: "Image payload was empty or exceeded the size limit", data: nil)
}

let sendResult = terminalPanel.surface.sendInputResult(escapedPath)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Send image paths through the paste path

When a mobile image paste reaches a terminal that has bracketed-paste or paste-specific handling enabled (the common path for TUIs such as Claude Code to recognize pasted file paths), this writes the generated path with sendInputResult, which routes text through the normal typed-input path (ghostty_surface_text_input) instead of the paste/clipboard path used by local image paste (sendText/clipboard completion in GhosttyTerminalView.swift). In that context the app receives ordinary keystrokes rather than a paste event, so the image path may not be handled as an attachment even though local clipboard-image paste works.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift (1)

614-641: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce a hard 10 MB cap before emitting pasted image bytes.

Line 624 is an 8 MB format-switch threshold, but Lines 629-635 still forward JPEG/PNG without a hard upper bound. That can push oversized payloads into terminal.paste_image and fail later instead of being rejected at source.

Suggested fix
 private func handlePasteAction() {
     let pasteboard = UIPasteboard.general
     if pasteboard.hasImages, let image = pasteboard.image {
-        let maxImageBytes = 8 * 1024 * 1024
-        if let png = image.pngData(), png.count <= maxImageBytes {
+        let jpegSwitchThreshold = 8 * 1024 * 1024
+        let maxUploadBytes = 10 * 1024 * 1024
+        let png = image.pngData()
+
+        if let png, png.count <= jpegSwitchThreshold, png.count <= maxUploadBytes {
             onPasteImage?(png, "png")
             return
         }
-        if let jpeg = image.jpegData(compressionQuality: 0.8) {
+
+        if let jpeg = image.jpegData(compressionQuality: 0.8), jpeg.count <= maxUploadBytes {
             onPasteImage?(jpeg, "jpg")
             return
         }
-        if let png = image.pngData() {
+
+        if let png, png.count <= maxUploadBytes {
             onPasteImage?(png, "png")
             return
         }
+        return
     }
     if pasteboard.hasStrings, let string = pasteboard.string, !string.isEmpty {
         onText?(string)
     }
 }
🤖 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/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift`
around lines 614 - 641, The paste handling currently uses an 8 MB threshold only
to prefer PNG vs JPEG but then may emit image bytes larger than 10 MB; update
handlePasteAction to enforce a hard 10 MB cap (e.g. let hardMaxBytes = 10 * 1024
* 1024) before calling onPasteImage, checking any pngData() or jpegData(...)
length and rejecting (or not calling onPasteImage) if the final bytes exceed the
cap; for JPEG, attempt incremental recompression (reducing compressionQuality)
until under the hard cap before emitting, otherwise fall back to rejecting the
paste. Ensure all branches in handlePasteAction reference this hardMaxBytes
check before invoking onPasteImage.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift`:
- Around line 1652-1665: submitTerminalPasteImage currently reads
selectedWorkspace and selectedTerminalID at send time which allows selection
drift to route the paste to the wrong terminal; instead capture the intended
target when the paste is initiated and pass it through to the async send. Change
submitTerminalPasteImage to accept (or be called with) the originating
identifiers (workspaceID and terminalID or a single originatingSurface token) so
it no longer reads selectedWorkspace/selectedTerminalID inside the async body,
and update the delegate caller to pass the originating surface/ids into
submitTerminalPasteImage; ensure sendRemoteTerminalPasteImage is invoked with
those captured ids instead of global selection.

In `@Sources/TerminalController.swift`:
- Around line 21348-21350: The code decodes base64 into Data immediately
(Data(base64Encoded:)) which can spike memory for huge payloads; instead first
bind the base64 string via v2RawString, compute an estimated decoded byte length
from base64 (e.g. floor(base64.count * 3 / 4) minus padding) and compare it to
the image size cap used by saveImageData, returning the same "invalid_params"
error if the estimate exceeds the cap, and only then call Data(base64Encoded:)
and proceed to saveImageData; apply the same change to the other occurrence that
uses v2RawString + Data(base64Encoded:) around the saveImageData call.

---

Duplicate comments:
In
`@Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift`:
- Around line 614-641: The paste handling currently uses an 8 MB threshold only
to prefer PNG vs JPEG but then may emit image bytes larger than 10 MB; update
handlePasteAction to enforce a hard 10 MB cap (e.g. let hardMaxBytes = 10 * 1024
* 1024) before calling onPasteImage, checking any pngData() or jpegData(...)
length and rejecting (or not calling onPasteImage) if the final bytes exceed the
cap; for JPEG, attempt incremental recompression (reducing compressionQuality)
until under the hard cap before emitting, otherwise fall back to rejecting the
paste. Ensure all branches in handlePasteAction reference this hardMaxBytes
check before invoking onPasteImage.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a7a6adf1-1020-4e10-9bee-cdc4c6aa50fb

📥 Commits

Reviewing files that changed from the base of the PR and between 5f6e1ae and 3776970.

📒 Files selected for processing (9)
  • Packages/CmuxMobileRPC/Sources/CmuxMobileRPC/MobileCoreRPCClient.swift
  • Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift
  • Packages/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
  • Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
  • Sources/GhosttyTerminalView.swift
  • Sources/Mobile/MobileHostService.swift
  • Sources/TerminalController.swift
  • ios/cmux/Resources/Localizable.xcstrings

Comment on lines +1652 to +1665
public func submitTerminalPasteImage(_ data: Data, format: String) async {
guard !data.isEmpty else { return }
guard let workspaceID = selectedWorkspace?.id,
let terminalID = selectedTerminalID else {
return
}
guard remoteClient != nil else { return }
await sendRemoteTerminalPasteImage(
data,
format: format,
workspaceID: workspaceID,
terminalID: terminalID
)
}

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 | 🟠 Major | ⚡ Quick win

Paste-image RPC can target the wrong terminal after selection drift.

This method derives the destination from global selection at send time. If the user switches terminals between paste capture and async dispatch, the image is injected into a different session than the one that triggered paste.

Suggested fix
- public func submitTerminalPasteImage(_ data: Data, format: String) async {
+ public func submitTerminalPasteImage(
+     _ data: Data,
+     format: String,
+     surfaceID: String
+ ) async {
    guard !data.isEmpty else { return }
-   guard let workspaceID = selectedWorkspace?.id,
-         let terminalID = selectedTerminalID else {
+   guard let workspaceID = workspaceID(forTerminalID: surfaceID) else {
        return
    }
    guard remoteClient != nil else { return }
    await sendRemoteTerminalPasteImage(
        data,
        format: format,
        workspaceID: workspaceID,
-       terminalID: terminalID
+       terminalID: .init(rawValue: surfaceID)
    )
 }

And call it from the delegate with the originating surface:

- await store?.submitTerminalPasteImage(data, format: format)
+ await store?.submitTerminalPasteImage(data, format: format, surfaceID: self.surfaceID)
🤖 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 1652 - 1665, submitTerminalPasteImage currently reads
selectedWorkspace and selectedTerminalID at send time which allows selection
drift to route the paste to the wrong terminal; instead capture the intended
target when the paste is initiated and pass it through to the async send. Change
submitTerminalPasteImage to accept (or be called with) the originating
identifiers (workspaceID and terminalID or a single originatingSurface token) so
it no longer reads selectedWorkspace/selectedTerminalID inside the async body,
and update the delegate caller to pass the originating surface/ids into
submitTerminalPasteImage; ensure sendRemoteTerminalPasteImage is invoked with
those captured ids instead of global selection.

Comment on lines +21348 to +21350
guard let base64 = v2RawString(params, "image_base64"),
let imageData = Data(base64Encoded: base64), !imageData.isEmpty else {
return .err(code: "invalid_params", message: "Missing or invalid image_base64", data: nil)

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 | 🟠 Major | ⚡ Quick win

Bound image_base64 before decoding to avoid avoidable memory spikes.

Data(base64Encoded:) allocates decoded bytes immediately. A very large payload can pressure memory before saveImageData enforces the size cap.

Suggested fix
 private func v2MobileTerminalPasteImage(params: [String: Any]) -> V2CallResult {
     guard let base64 = v2RawString(params, "image_base64"),
-          let imageData = Data(base64Encoded: base64), !imageData.isEmpty else {
+          !base64.isEmpty else {
         return .err(code: "invalid_params", message: "Missing or invalid image_base64", data: nil)
     }
+    let maxBytes = 10 * 1024 * 1024
+    // Base64 expands by ~4/3; reject obviously oversized payloads before decode.
+    let estimatedDecodedBytes = (base64.utf8.count * 3) / 4
+    guard estimatedDecodedBytes <= maxBytes,
+          let imageData = Data(base64Encoded: base64),
+          !imageData.isEmpty,
+          imageData.count <= maxBytes else {
+        return .err(code: "invalid_params", message: "Image payload was empty or exceeded the size limit", data: nil)
+    }
     let format = v2RawString(params, "image_format") ?? "png"

Also applies to: 21367-21369

🤖 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 `@Sources/TerminalController.swift` around lines 21348 - 21350, The code
decodes base64 into Data immediately (Data(base64Encoded:)) which can spike
memory for huge payloads; instead first bind the base64 string via v2RawString,
compute an estimated decoded byte length from base64 (e.g. floor(base64.count *
3 / 4) minus padding) and compare it to the image size cap used by
saveImageData, returning the same "invalid_params" error if the estimate exceeds
the cap, and only then call Data(base64Encoded:) and proceed to saveImageData;
apply the same change to the other occurrence that uses v2RawString +
Data(base64Encoded:) around the saveImageData call.

This branch was successfully deployed

1 active deployment
Preview – cmux — 37769702 Deployed Jun 7, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant