Skip to content

feat: Shift+click to open links in system browser#2909

Closed
ClementRouquier wants to merge 1 commit into
manaflow-ai:mainfrom
ClementRouquier:feat/shift-click-system-browser
Closed

feat: Shift+click to open links in system browser#2909
ClementRouquier wants to merge 1 commit into
manaflow-ai:mainfrom
ClementRouquier:feat/shift-click-system-browser

Conversation

@ClementRouquier
Copy link
Copy Markdown

@ClementRouquier ClementRouquier commented Apr 15, 2026

Summary

  • Cmd+Shift+click on terminal links opens them in the system default browser instead of the embedded browser
  • Shift+click on links in the browser panel/popup also opens in system browser

Approach

Ghostty only fires OPEN_URL when modifiers exactly match its link-mods config (Cmd on macOS). Adding Shift breaks both hover underline and the action. As a workaround, this PR strips Shift from the mods sent to Ghostty when Cmd is held — in hoverModsFromFlags, flagsChanged, mouseDown, and mouseUp — so Ghostty still sees plain Cmd. The real Shift state is then read via NSEvent.modifierFlags in the OPEN_URL handler to force system browser.

Known concern: This silently hides Shift from Ghostty for all mouse events when Cmd is held. A cleaner long-term approach would be a Ghostty C API like ghostty_surface_link_url_at_pos() to query the link under the cursor without triggering the action — then no modifier stripping is needed. Happy to rework if Ghostty exposes this.

Test plan

  • Cmd+click on a terminal link → opens in embedded browser (unchanged)
  • Cmd+Shift+click on a terminal link → opens in system browser
  • Hover underline stays visible when adding Shift to Cmd
  • No flicker when pressing/releasing Shift while Cmd is held
  • Shift+click on a link in the browser panel → opens in system browser
  • Cmd+click on a browser panel link → opens in new tab (unchanged)
  • Terminal text selection with Shift+click (no Cmd) still works

🤖 Generated with Claude Code


Summary by cubic

Shift+click opens links in the system default browser: Cmd+Shift+click for terminal links, and Shift+click for links in the embedded browser. Cmd+click behavior is unchanged.

  • New Features
    • Terminal: Cmd+Shift+click opens in the system browser while keeping hover underline and regular Cmd+click behavior.
    • Browser panel/popup: Shift+click opens externally; Cmd+click still opens in a new tab.

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

Summary by CodeRabbit

  • New Features

    • Shift+clicking links now opens them in your system default browser instead of the app's embedded browser, providing direct access to your preferred browser.
  • Bug Fixes

    • Enhanced keyboard modifier detection and normalization for more reliable link interaction behavior across all interface elements.

Cmd+Shift+click on terminal links and browser panel links opens them
in the system default browser instead of the embedded browser.

Terminal: strips Shift from mods sent to Ghostty so it still treats
Cmd+Shift as a link click, then checks NSEvent.modifierFlags for Shift
in the OPEN_URL handler to force external open. This is a workaround
until Ghostty exposes a link-query API (see PR description).

Browser panel + popup: checks navigationAction.modifierFlags for Shift
on link-activated navigations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 15, 2026

@ClementRouquier is attempting to deploy a commit to the Manaflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 15, 2026

📝 Walkthrough

Walkthrough

This PR implements Shift+click behavior to open links in the system default browser across terminal and browser panel components, replacing embedded-browser selection logic with direct NSWorkspace calls when Shift is held.

Changes

Cohort / File(s) Summary
Terminal Link Handling
Sources/GhosttyTerminalView.swift
Introduced linkModsFromEvent(_:) helper to adjust modifier encoding by stripping .shift when .command is also held. Updated GHOSTTY_ACTION_OPEN_URL handler to open URLs via system browser when Shift is pressed. Modified mouse link targeting to use the new modifier function for left-button events and hover state.
Browser Navigation Interception
Sources/Panels/BrowserPanel.swift, Sources/Panels/BrowserPopupWindowController.swift
Added Shift+click interception in navigation decision handlers: when Shift modifier is detected on activated link navigations, URLs are opened via NSWorkspace.shared.open(url) and WebKit navigation is canceled, allowing standard system browser handling instead of embedded browser routing.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related PRs

Poem

🐰 A shift and a click, oh what delight!
Through system browser our links take flight,
No embedded walls shall hold us back,
The modifier dance on the keyboard track,
Hopping through URLs, free and bright! 🌐

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% 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 accurately and concisely describes the main feature: enabling Shift+click to open links in the system browser. It directly aligns with the primary change across all modified files.
Description check ✅ Passed The description covers the summary and testing sections well with clear explanations of the feature and implementation approach. However, the test plan section uses unchecked checkboxes rather than describing actual verification performed, and no demo video is provided.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@ClementRouquier ClementRouquier marked this pull request as draft April 15, 2026 06:11
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Apr 15, 2026

Greptile Summary

This PR adds Shift+click support to open links in the system browser: terminal links via Cmd+Shift+click (by stripping Shift from mods sent to Ghostty and checking NSEvent.modifierFlags in the OPEN_URL handler) and browser panel/popup links via plain Shift+click (by intercepting linkActivated navigations with .shift in modifierFlags). The modifier-stripping workaround is a pragmatic interim approach pending a Ghostty link-query API.

Confidence Score: 4/5

Safe to merge after addressing the linkModsFromEvent misuse in flagsChanged keyboard events; all other findings are minor style/consistency issues.

One P1 finding: linkModsFromEvent is applied to the keyboard modifier-tracking event in flagsChanged, sending Ghostty a self-contradictory modifier state (Shift key pressed but Shift absent from mods). This could corrupt Ghostty's modifier tracking under Cmd+Shift keyboard input. The fix is a one-line revert to modsFromEvent(event). Remaining comments are P2 style/consistency issues that don't block merge.

Sources/GhosttyTerminalView.swift — specifically the flagsChanged keyboard modifier event at line 7056

Important Files Changed

Filename Overview
Sources/GhosttyTerminalView.swift Adds linkModsFromEvent to strip Shift when Cmd is held; applies it in mouseDown, mouseUp, flagsChanged key events, and hoverModsFromFlags; reads NSEvent.modifierFlags in the OPEN_URL handler to decide system vs. embedded browser. The flagsChanged change also strips Shift from keyboard modifier-tracking events sent to Ghostty, which may produce inconsistent modifier state.
Sources/Panels/BrowserPanel.swift Adds Shift+click → system browser check in decidePolicyFor navigationAction, positioned correctly after the insecure HTTP and external-URL guards.
Sources/Panels/BrowserPopupWindowController.swift Adds Shift+click → system browser check in PopupNavigationDelegate.decidePolicyFor, but placed before the insecure HTTP check, unlike the main browser panel where insecure HTTP is checked first.

Sequence Diagram

sequenceDiagram
    participant User
    participant macOS
    participant GhosttyTerminalView
    participant Ghostty
    participant BrowserPanel
    participant SystemBrowser

    Note over User,SystemBrowser: Cmd+Shift+click on terminal link
    User->>macOS: Press Cmd+Shift, click link
    macOS->>GhosttyTerminalView: mouseDown(event)
    GhosttyTerminalView->>GhosttyTerminalView: linkModsFromEvent() strips Shift
    GhosttyTerminalView->>Ghostty: mouse_pos(mods=Cmd only)
    GhosttyTerminalView->>Ghostty: mouse_button(PRESS, mods=Cmd only)
    macOS->>GhosttyTerminalView: mouseUp(event)
    GhosttyTerminalView->>Ghostty: mouse_button(RELEASE, mods=Cmd only)
    Ghostty->>GhosttyTerminalView: OPEN_URL callback
    GhosttyTerminalView->>macOS: NSEvent.modifierFlags.contains(.shift)?
    macOS-->>GhosttyTerminalView: true
    GhosttyTerminalView->>SystemBrowser: NSWorkspace.open(url)

    Note over User,SystemBrowser: Shift+click on browser panel link
    User->>macOS: Press Shift, click link
    macOS->>BrowserPanel: decidePolicyFor(navigationAction)
    BrowserPanel->>BrowserPanel: modifierFlags.contains(.shift) and linkActivated?
    BrowserPanel-->>BrowserPanel: true
    BrowserPanel->>SystemBrowser: NSWorkspace.open(url)
    BrowserPanel->>BrowserPanel: decisionHandler(.cancel)
Loading

Reviews (1): Last reviewed commit: "feat: Shift+click to open links in syste..." | Re-trigger Greptile

keyEvent.action = action
keyEvent.keycode = UInt32(event.keyCode)
keyEvent.mods = modsFromEvent(event)
keyEvent.mods = linkModsFromEvent(event)
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 linkModsFromEvent misapplied to keyboard modifier-tracking events

flagsChanged sends Ghostty a modifier-key event to keep its internal modifier state in sync. When Shift is pressed while Cmd is held, linkModsFromEvent strips Shift from the mods field, so Ghostty receives a PRESS event for the Shift keycode but with a modifier state that doesn't include Shift — a self-contradictory event. Ghostty may use this field when generating modifier-based escape sequences for subsequent keyboard input, risking wrong output.

The stripping is only necessary for the mouse paths (ghostty_surface_mouse_pos / ghostty_surface_mouse_button) so Ghostty treats Cmd+Shift as a plain Cmd link click. For this keyboard modifier-tracking event, modsFromEvent(event) (the prior behavior) is correct. Consider reverting just this line to modsFromEvent(event).

Comment on lines +7408 to +7410
ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, linkModsFromEvent(event))
}
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, linkModsFromEvent(event))
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 linkModsFromEvent called twice in mouseDown

linkModsFromEvent(event) is a pure function but is called twice, once for mouse_pos and once for mouse_button. Caching the result in a local variable avoids the redundant call.

Suggested change
ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, linkModsFromEvent(event))
}
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, modsFromEvent(event))
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, linkModsFromEvent(event))
let mouseMods = linkModsFromEvent(event)
if event.clickCount == 1 {
ghostty_surface_mouse_pos(surface, eventPoint.x, bounds.height - eventPoint.y, mouseMods)
}
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mouseMods)

Comment on lines +571 to +580
// Shift+click: bypass embedded browser and open in system default browser.
if navigationAction.modifierFlags.contains(.shift),
navigationAction.navigationType == .linkActivated {
#if DEBUG
dlog("popup.nav.shiftClickExternal url=\(url.absoluteString)")
#endif
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
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.

P2 Shift+click bypasses insecure HTTP warning in popup

The Shift+click check here fires before the insecure HTTP guard (line 583), so Shift+clicking an http:// link in a popup opens it directly in the system browser without the security alert. The main BrowserPanel places the insecure HTTP check before the Shift+click check, so those two paths are inconsistent. Since the user's intent with Shift+click is explicitly to delegate to the system browser (which has its own HTTP warnings), this is arguably harmless — but worth aligning for consistency.

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/GhosttyTerminalView.swift`:
- Around line 3260-3267: The callback is reading NSEvent.modifierFlags (global
state) instead of the latched Shift from the original click; modify the input
path so the Shift bit is captured at mouseDown/mouseUp and carried to the
Ghostty callback (or stored on GhosttyTerminalView) and then read in the
link-opening code instead of NSEvent.modifierFlags. Concretely: add a boolean
(e.g., shiftLatched) to the data passed through ghostty_action_s or as a
temporary property on GhosttyTerminalView set during mouseDown/mouseUp, set it
from event.modifierFlags.contains(.shift) at the original event, propagate it
into handleAction, and replace uses of NSEvent.modifierFlags.contains(.shift)
with that latched boolean (update any relevant functions that construct or
consume the ghostty_action_s).

In `@Sources/Panels/BrowserPanel.swift`:
- Around line 6174-6184: The shift-click external-open path currently
unconditionally calls NSWorkspace.shared.open(url) then
decisionHandler(.cancel); update the logic in the BrowserPanel navigation
handler so you capture the Bool result from NSWorkspace.shared.open(url) and
only call decisionHandler(.cancel) when it returns true, otherwise call
decisionHandler(.allow) (and optionally log the failure via dlog in DEBUG).
Locate the shift-click branch handling navigationAction, the
NSWorkspace.shared.open call and the decisionHandler invocation to implement
this conditional fallback.
🪄 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: 577a33cb-df40-44b3-a632-8934d51c864d

📥 Commits

Reviewing files that changed from the base of the PR and between c5f2e8c and bf290a5.

📒 Files selected for processing (3)
  • Sources/GhosttyTerminalView.swift
  • Sources/Panels/BrowserPanel.swift
  • Sources/Panels/BrowserPopupWindowController.swift

Comment on lines +3260 to +3267
// Shift+click: bypass embedded browser, open in system default browser.
if NSEvent.modifierFlags.contains(.shift) {
#if DEBUG
dlog("link.openURL shift=true, forcing system browser url=\(target.url)")
#endif
return performOnMain {
NSWorkspace.shared.open(target.url)
}
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

🌐 Web query:

In AppKit, does NSEvent.modifierFlags return the current global modifier state, or the modifier flags from the event that triggered a later callback?

💡 Result:

In AppKit, NSEvent.modifierFlags (the instance property on an NSEvent object) returns the modifier flags from the specific event that triggered the callback, not the current global modifier state.

Citations:


🏁 Script executed:

cd Sources && sed -n '3240,3280p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 2054


🏁 Script executed:

cd Sources && sed -n '3200,3280p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 4608


🏁 Script executed:

cd Sources && sed -n '3050,3150p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 5577


🏁 Script executed:

cd Sources && sed -n '2900,2950p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 2400


🏁 Script executed:

cd Sources && grep -n "func handleAction" GhosttyTerminalView.swift | head -5

Repository: manaflow-ai/cmux

Length of output: 156


🏁 Script executed:

cd Sources && sed -n '2857,2900p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 2723


🏁 Script executed:

cd Sources && rg -n "GHOSTTY_ACTION_OPEN_URL" --type swift | head -20

Repository: manaflow-ai/cmux

Length of output: 128


🏁 Script executed:

cd Sources && rg -n "handleAction\(" GhosttyTerminalView.swift | head -10

Repository: manaflow-ai/cmux

Length of output: 243


🏁 Script executed:

cd Sources && sed -n '1660,1680p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 1256


🏁 Script executed:

cd Sources && sed -n '7400,7430p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 1915


🏁 Script executed:

cd Sources && sed -n '7500,7600p' GhosttyTerminalView.swift | cat -n

Repository: manaflow-ai/cmux

Length of output: 4667


🏁 Script executed:

cd Sources && rg "NSEvent\.modifierFlags" GhosttyTerminalView.swift -B 2 -A 2

Repository: manaflow-ai/cmux

Length of output: 330


🏁 Script executed:

cd Sources && rg "ghostty_action_s" GhosttyTerminalView.swift | head -20

Repository: manaflow-ai/cmux

Length of output: 482


🏁 Script executed:

cd Sources && rg "class var modifierFlags\|static var modifierFlags" --type swift

Repository: manaflow-ai/cmux

Length of output: 42


🏁 Script executed:

cd Sources && python3 << 'EOF'
# Check if NSEvent.modifierFlags is being called as class property (which returns current state)
# or instance property (which returns event's flags)
import subprocess
result = subprocess.run(['rg', 'NSEvent\\.modifierFlags', '-A', '5', '-B', '5'], 
                       capture_output=True, text=True)
print("Search results for NSEvent.modifierFlags:")
print(result.stdout)
EOF

Repository: manaflow-ai/cmux

Length of output: 102


🏁 Script executed:

cd Sources && rg "ghostty_action_s\|ghostty_action_open_url" --type swift -B 2 -A 10 | head -50

Repository: manaflow-ai/cmux

Length of output: 42


Latch the click's Shift bit instead of reading global modifier state.

Line 3262 uses NSEvent.modifierFlags (the class property), which returns the current global modifier state, not the modifiers from the original click event. The handleAction callback receives only a ghostty_action_s struct—the original NSEvent is unavailable. If Shift is released between mouseUp and this callback, the same Cmd+Shift+click can fall back to the embedded browser instead of opening externally.

Capture the Shift state from the event during mouseDown/mouseUp and propagate it through the Ghostty callback chain (or store it temporarily) so this check reads the latched value instead of global state.

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

In `@Sources/GhosttyTerminalView.swift` around lines 3260 - 3267, The callback is
reading NSEvent.modifierFlags (global state) instead of the latched Shift from
the original click; modify the input path so the Shift bit is captured at
mouseDown/mouseUp and carried to the Ghostty callback (or stored on
GhosttyTerminalView) and then read in the link-opening code instead of
NSEvent.modifierFlags. Concretely: add a boolean (e.g., shiftLatched) to the
data passed through ghostty_action_s or as a temporary property on
GhosttyTerminalView set during mouseDown/mouseUp, set it from
event.modifierFlags.contains(.shift) at the original event, propagate it into
handleAction, and replace uses of NSEvent.modifierFlags.contains(.shift) with
that latched boolean (update any relevant functions that construct or consume
the ghostty_action_s).

Comment on lines +6174 to +6184
// Shift+click: bypass embedded browser and open in system default browser.
if navigationAction.modifierFlags.contains(.shift),
navigationAction.navigationType == .linkActivated,
let url = navigationAction.request.url {
#if DEBUG
dlog("browser.nav.decidePolicy.action kind=shiftClickExternal url=\(url.absoluteString)")
#endif
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
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 | 🟡 Minor

Handle failed external-open gracefully before canceling navigation.

If NSWorkspace.shared.open(url) fails, this path still cancels the navigation, so the click does nothing. Please only cancel on successful handoff (or fall back to .allow).

💡 Suggested fix
         if navigationAction.modifierFlags.contains(.shift),
            navigationAction.navigationType == .linkActivated,
            let url = navigationAction.request.url {
             `#if` DEBUG
             dlog("browser.nav.decidePolicy.action kind=shiftClickExternal url=\(url.absoluteString)")
             `#endif`
-            NSWorkspace.shared.open(url)
-            decisionHandler(.cancel)
-            return
+            let opened = NSWorkspace.shared.open(url)
+            if opened {
+                decisionHandler(.cancel)
+                return
+            }
+            decisionHandler(.allow)
+            return
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Panels/BrowserPanel.swift` around lines 6174 - 6184, The shift-click
external-open path currently unconditionally calls NSWorkspace.shared.open(url)
then decisionHandler(.cancel); update the logic in the BrowserPanel navigation
handler so you capture the Bool result from NSWorkspace.shared.open(url) and
only call decisionHandler(.cancel) when it returns true, otherwise call
decisionHandler(.allow) (and optionally log the failure via dlog in DEBUG).
Locate the shift-click branch handling navigationAction, the
NSWorkspace.shared.open call and the decisionHandler invocation to implement
this conditional fallback.

@ClementRouquier
Copy link
Copy Markdown
Author

Closing as duplicate of #1470 which takes the cleaner approach of modifying the Ghostty submodule directly.

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