Skip to content

Enable macOS Services menu on terminal right-click#2699

Open
austinywang wants to merge 3 commits intomainfrom
issue-2698-services-menu
Open

Enable macOS Services menu on terminal right-click#2699
austinywang wants to merge 3 commits intomainfrom
issue-2698-services-menu

Conversation

@austinywang
Copy link
Copy Markdown
Contributor

@austinywang austinywang commented Apr 7, 2026

Summary

  • make GhosttyNSView conform to NSServicesMenuRequestor so AppKit can expose native Services for selected terminal text
  • write terminal selections to the Services pasteboard and route returned strings back through the existing terminal text-input path
  • remove stale BonsplitConfiguration.Appearance.tabTitleFontSize usage so this checkout builds against the current vendor/bonsplit API

Verification

  • built and launched with ./scripts/reload.sh --tag issue-2698-services-menu --launch
  • did not run local tests per repo policy

Closes #2698


Note

Medium Risk
Adds macOS Services pasteboard integration to terminal selection/input and tweaks workspace appearance updates; regressions could affect selection availability, text insertion, or tab-strip styling but scope is limited to UI/input paths.

Overview
Enables native macOS Services for terminal selections by making GhosttyNSView an NSServicesMenuRequestor, exporting the current selection to the Services pasteboard and accepting returned text back into the terminal via the existing sendText/paste path (including multiline payloads).

Updates workspace chrome application to derive the next BonsplitConfiguration.Appearance via bonsplitAppearance(from: config) and only apply background/tab-title font size when they materially change, aligning with the current bonsplit API and tightening related debug logging/thresholds.

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


Summary by cubic

Adds native macOS Services to the terminal so you can right-click selected text, run a Service, and have the result pasted back into the terminal. Also fixes tab title font sizing/divider regressions and aligns appearance updates with the current vendor/bonsplit API. Closes #2698.

  • New Features

    • GhosttyNSView now conforms to NSServicesMenuRequestor to expose Services for selected text.
    • Selection is written to the Services pasteboard; returned text is routed through the paste path so multiline payloads are handled correctly.
  • Bug Fixes

    • Restore and stabilize tab title font sizing: compute via bonsplitAppearance(from:), apply only when changed, and initialize new workspaces from cached config to avoid startup mismatch.
    • Update vendor/bonsplit to include the divider fix.

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

Summary by CodeRabbit

  • New Features

    • Added macOS Services/Dictation integration so terminal selections can be shared with system services and incoming service-pasted text can be accepted.
  • Refactor

    • Centralized appearance/theme application with improved change detection and clearer background/tab-size logging.
  • Chore

    • Updated bundled appearance library to a newer vendored revision.

@vercel
Copy link
Copy Markdown

vercel bot commented Apr 7, 2026

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

Project Deployment Actions Updated (UTC)
cmux Ready Ready Preview, Comment Apr 9, 2026 10:03pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 7, 2026

📝 Walkthrough

Walkthrough

Implement macOS Services pasteboard integration on Ghostty's NSView for sending/receiving terminal selection; adjust Workspace appearance application to compute next appearance via bonsplitAppearance(from:), tweak background/font-size comparison and logging; update the vendored bonsplit submodule commit.

Changes

Cohort / File(s) Summary
macOS Services Integration
Sources/GhosttyTerminalView.swift
Add extension GhosttyNSView: NSServicesMenuRequestor implementing validRequestor(forSendType:returnType:), writeSelection(to:types:), and readSelection(from:) to advertise selection to Services and accept returned text into the terminal input pipeline.
Bonsplit / Appearance Logic
Sources/Workspace.swift
Use Self.bonsplitAppearance(from: config) to derive next appearance; compare chromeColors.backgroundHex for background changes; use abs(... ) with epsilon 0.001 for tab font-size noop detection; format log values with %.2f; apply nextAppearance fields when updating controller. Minor comment tweaks around initial appearance construction.
Vendored Dependency Update
vendor/bonsplit
Update vendored bonsplit submodule reference to a newer commit (submodule commit hash bumped).

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant macOS as "macOS Services\nMenu System"
    participant GhosttyNSView as "GhosttyNSView\n(NSServicesMenuRequestor)"
    participant Pasteboard as Pasteboard
    participant GhosttyTerminal as "Ghostty\nSurface"

    User->>macOS: Invoke Services on selection
    macOS->>GhosttyNSView: validRequestor(sendType: .string)
    GhosttyNSView->>GhosttyTerminal: ghostty_surface_has_selection()
    GhosttyTerminal-->>GhosttyNSView: hasSelection (true)
    GhosttyNSView-->>macOS: return self

    macOS->>GhosttyNSView: writeSelection(to: pasteboard)
    GhosttyNSView->>GhosttyTerminal: snapshot selection
    GhosttyTerminal-->>GhosttyNSView: selectedText
    GhosttyNSView->>Pasteboard: setString(selectedText, forType: .string)

    rect rgba(100,200,100,0.5)
    Note over macOS,Pasteboard: External Service may process/return text
    end

    macOS->>GhosttyNSView: readSelection(from: pasteboard)
    GhosttyNSView->>Pasteboard: GhosttyPasteboardHelper.stringContents
    Pasteboard-->>GhosttyNSView: processedText
    GhosttyNSView->>GhosttyTerminal: withExternalCommittedText { insertText(processedText) }
    GhosttyTerminal-->>GhosttyNSView: done
    GhosttyNSView-->>macOS: true
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰
I hopped to the terminal with a pasteboard in paw,
Gave macOS some text and received it back—no flaw.
Bonsplit dressed up fonts and hues with a nip and tuck,
I twitched, I clicked, I pasted — a rabbit-sized pluck.
Hop, send, return — code and carrots, tucked!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title directly and clearly summarizes the primary change: enabling the macOS Services menu for terminal right-click selections, which is the main feature added in this PR.
Description check ✅ Passed The PR description covers the main changes (Services menu, workspace appearance updates, vendor/bonsplit) and includes verification details and issue reference.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 issue-2698-services-menu

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.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 7, 2026

Greptile Summary

This PR adds NSServicesMenuRequestor conformance to GhosttyNSView, wiring selected terminal text to the macOS Services pasteboard via writeSelection and routing returned strings back into the terminal through the existing withExternalCommittedText/insertText path. It also removes the stale tabTitleFontSize field from the BonsplitConfiguration.Appearance initializer in Workspace.swift to build against the current bonsplit API.

Confidence Score: 5/5

PR is safe to merge; all remaining findings are P2 style suggestions with no runtime impact.

The NSServicesMenuRequestor implementation is correct and well-integrated with existing selection and text-input infrastructure. Both findings are minor protocol-contract style deviations that have no practical effect given AppKit's type equivalences and the existing guards in validRequestor.

Sources/GhosttyTerminalView.swift — NSServicesMenuRequestor extension (lines 11712–11758)

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds NSServicesMenuRequestor extension; logic is correct but writeSelection ignores the types parameter and validRequestor has a redundant inner type check (both P2).
Sources/Workspace.swift Removes stale tabTitleFontSize field from BonsplitConfiguration.Appearance initializer to match current bonsplit API.

Sequence Diagram

sequenceDiagram
    participant User
    participant AppKit
    participant GhosttyNSView
    participant GhosttyC as ghostty C API
    participant Pasteboard

    User->>AppKit: Right-click → Services
    AppKit->>GhosttyNSView: validRequestor(forSendType:returnType:)
    GhosttyNSView->>GhosttyC: ghostty_surface_has_selection(surface)
    GhosttyC-->>GhosttyNSView: Bool
    GhosttyNSView-->>AppKit: self (or super/nil)

    User->>AppKit: Select service action
    AppKit->>GhosttyNSView: writeSelection(to:types:)
    GhosttyNSView->>GhosttyC: ghostty_surface_read_selection(surface, &text)
    GhosttyC-->>GhosttyNSView: text data
    GhosttyNSView->>Pasteboard: declareTypes + setString(selectedText)

    AppKit->>AppKit: Service transforms text
    AppKit->>GhosttyNSView: readSelection(from:)
    GhosttyNSView->>Pasteboard: GhosttyPasteboardHelper.stringContents(from:)
    Pasteboard-->>GhosttyNSView: transformed string
    GhosttyNSView->>GhosttyNSView: withExternalCommittedText { insertText(...) }
Loading

Reviews (1): Last reviewed commit: "Enable terminal Services requestor" | Re-trigger Greptile

Comment on lines +11736 to +11745
func writeSelection(
to pboard: NSPasteboard,
types: [NSPasteboard.PasteboardType]
) -> Bool {
guard let snapshot = readSelectionSnapshot() else { return false }

pboard.declareTypes([.string], owner: nil)
pboard.setString(snapshot.string, forType: .string)
return true
}
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.

P2 writeSelection ignores the types parameter

The NSServicesMenuRequestor protocol contract requires writing only the types listed in types that the object supports, but the parameter is silently ignored and .string is always written. This is harmless in practice since validRequestor already gates on supported types, but it departs from the protocol contract.

Suggested change
func writeSelection(
to pboard: NSPasteboard,
types: [NSPasteboard.PasteboardType]
) -> Bool {
guard let snapshot = readSelectionSnapshot() else { return false }
pboard.declareTypes([.string], owner: nil)
pboard.setString(snapshot.string, forType: .string)
return true
}
func writeSelection(
to pboard: NSPasteboard,
types: [NSPasteboard.PasteboardType]
) -> Bool {
let supportedTypes: [NSPasteboard.PasteboardType] = [.string, .init("public.utf8-plain-text")]
guard types.isEmpty || types.contains(where: { supportedTypes.contains($0) }) else { return false }
guard let snapshot = readSelectionSnapshot() else { return false }
pboard.declareTypes([.string], owner: nil)
pboard.setString(snapshot.string, forType: .string)
return true
}

Comment on lines +11722 to +11728
if (returnType == nil || supportedTypes.contains(returnType!)) &&
(sendType == nil || supportedTypes.contains(sendType!)) {
if let sendType, supportedTypes.contains(sendType) {
guard let surface, ghostty_surface_has_selection(surface) else {
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
}
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.

P2 Redundant inner type check in validRequestor

The supportedTypes.contains(sendType) condition inside the if let sendType binding is always true at that point — the outer if already guarantees sendType == nil || supportedTypes.contains(sendType!), and since sendType is non-nil here (unwrapped by if let), it is already in supportedTypes.

Suggested change
if (returnType == nil || supportedTypes.contains(returnType!)) &&
(sendType == nil || supportedTypes.contains(sendType!)) {
if let sendType, supportedTypes.contains(sendType) {
guard let surface, ghostty_surface_has_selection(surface) else {
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
}
if (returnType == nil || supportedTypes.contains(returnType!)) &&
(sendType == nil || supportedTypes.contains(sendType!)) {
if let sendType {
guard let surface, ghostty_surface_has_selection(surface) else {
return super.validRequestor(forSendType: sendType, returnType: returnType)
}
}
return self
}

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

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

No issues found across 2 files

Copy link
Copy Markdown

@cursor cursor bot left a comment

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.

Fix All in Cursor

Bugbot Autofix is kicking off a free cloud agent to fix this issue. This run is complimentary, but you can enable autofix for all future PRs in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 3340e71. Configure here.

let supportedTypes: [NSPasteboard.PasteboardType] = [
.string,
.init("public.utf8-plain-text")
]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate entries in supported pasteboard types array

Low Severity

NSPasteboard.PasteboardType.string has the raw value "public.utf8-plain-text", so .string and .init("public.utf8-plain-text") are the same type. The supportedTypes array contains two identical entries. If the intent was to also cover the legacy Objective-C "NSStringPboardType" pasteboard type, the wrong string was used. The codebase already defines a constant for this at GhosttyPasteboardHelper.utf8PlainTextType.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3340e71. Configure here.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Sources/Workspace.swift`:
- Around line 6872-6875: The initial Bonsplit appearance is created with
bonsplitAppearance(from:backgroundOpacity:) which defaults tabTitleFontSize to
11, so update the call that currently uses
GhosttyApp.shared.defaultBackgroundColor and
GhosttyApp.shared.defaultBackgroundOpacity to also pass the configured font size
(GhosttyConfig.surfaceTabBarFontSize) into bonsplitAppearance's tabTitleFontSize
parameter so new workspaces start with the correct tab font size; locate the
call to bonsplitAppearance(…) and add the tabTitleFontSize argument set to
GhosttyConfig.surfaceTabBarFontSize.

In `@vendor/bonsplit`:
- Line 1: The parent repo updated the vendor/bonsplit submodule to commit
d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef but that commit is not on bonsplit's
origin/main; push that commit to the bonsplit remote first, verify it is an
ancestor of origin/main (e.g., fetch and check merge-base for commit
d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef), then update the parent repo's
vendor/bonsplit pointer to the pushed commit so the submodule reference is not
dangling.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1325a357-47e4-435d-94a1-ac063e15e628

📥 Commits

Reviewing files that changed from the base of the PR and between 3340e71 and 431ff55.

📒 Files selected for processing (2)
  • Sources/Workspace.swift
  • vendor/bonsplit

@@ -1 +1 @@
Subproject commit 098d9fa00e2b1d4712f1a46b818ee7d53d4aa31f
Subproject commit d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Verify that the new bonsplit submodule commit exists on origin/main.
# Expected: Command exits 0 if commit is an ancestor of origin/main.

cd vendor/bonsplit
git fetch origin main --quiet
if git merge-base --is-ancestor d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef origin/main; then
  echo "✅ Commit d65b4a8 is on origin/main"
else
  echo "❌ Commit d65b4a8 is NOT on origin/main — submodule pointer may be dangling"
  exit 1
fi

Repository: manaflow-ai/cmux

Length of output: 323


Push submodule commit to bonsplit's origin/main before updating the parent repo pointer.

Verification confirms that the new submodule commit d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef does not exist on origin/main. Per repo policy, submodule commits must be pushed to the remote main branch before the parent repo updates its pointer to avoid dangling references and ensure reproducible builds.

Steps:

  1. Push the commit to bonsplit's origin/main
  2. Verify with: cd vendor/bonsplit && git fetch origin main && git merge-base --is-ancestor d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef origin/main
  3. Update the submodule pointer in the parent repo
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@vendor/bonsplit` at line 1, The parent repo updated the vendor/bonsplit
submodule to commit d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef but that commit is
not on bonsplit's origin/main; push that commit to the bonsplit remote first,
verify it is an ancestor of origin/main (e.g., fetch and check merge-base for
commit d65b4a8e1a733fa6ea7dac2d591289081b0dc2ef), then update the parent repo's
vendor/bonsplit pointer to the pushed commit so the submodule reference is not
dangling.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
Sources/Workspace.swift (1)

6786-6812: Use a consistent epsilon for tab font-size change detection.

Line 6791 uses 0.001, which is looser than the 0.0001 tolerance used in nearby float change checks and can skip small real updates. Consider aligning this threshold.

Proposed adjustment
-        let fontSizeChanged = abs(currentAppearance.tabTitleFontSize - nextAppearance.tabTitleFontSize) > 0.001
+        let fontSizeChanged = abs(currentAppearance.tabTitleFontSize - nextAppearance.tabTitleFontSize) > 0.0001

Also applies to: 6818-6818

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Workspace.swift` around lines 6786 - 6812, The tab font-size
comparison in Workspace (in the block using Self.bonsplitAppearance and
bonsplitController.configuration.appearance) currently uses an epsilon of 0.001;
change that to the consistent 0.0001 used elsewhere so fontSizeChanged is
computed with abs(currentAppearance.tabTitleFontSize -
nextAppearance.tabTitleFontSize) > 0.0001, and update any other identical
comparisons (e.g., the second occurrence around the same workspace appearance
update) to the same 0.0001 tolerance to keep behavior consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@Sources/Workspace.swift`:
- Around line 6786-6812: The tab font-size comparison in Workspace (in the block
using Self.bonsplitAppearance and bonsplitController.configuration.appearance)
currently uses an epsilon of 0.001; change that to the consistent 0.0001 used
elsewhere so fontSizeChanged is computed with
abs(currentAppearance.tabTitleFontSize - nextAppearance.tabTitleFontSize) >
0.0001, and update any other identical comparisons (e.g., the second occurrence
around the same workspace appearance update) to the same 0.0001 tolerance to
keep behavior consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4633abd9-95e8-4b83-988a-1af5e7f0168b

📥 Commits

Reviewing files that changed from the base of the PR and between 431ff55 and d277da6.

📒 Files selected for processing (2)
  • Sources/GhosttyTerminalView.swift
  • Sources/Workspace.swift

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.

Enable macOS Native "Services" Menu on Right-Click

1 participant