From 5ee5859f2188041a2e355ba66d0fd58aef7ad8a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:02:34 -0700 Subject: [PATCH] feat(browser): let Shift invert link routing instead of always forcing the system browser Shift+Cmd/Ctrl-click has always meant "open in the system browser", which is a no-op when that is already where links go. Users who keep Link Routing off have had no gesture to pull a single link into Orca's built-in browser. Adds "Hold Shift to open in ___", a nested toggle under Link Routing that makes the modifier open a link the opposite way from the setting. It ships off, so the one-way escape hatch is unchanged for every existing user. The title and description name the destination the modifier actually reaches and flip with the parent setting, since "the opposite" is meaningless on its own. - openHttpLink gains modifierHeld; resolveModifierRouting owns the decision so every surface (terminal URLs, OSC 8, xterm web links, markdown preview) routes identically. forceSystemBrowser stays for callers that must bypass settings. - The terminal hover hint names Orca when the modifier would open there, and is re-resolved per hover so toggling applies without recreating panes. - Link Routing's own copy drops "always uses your system browser", which the new toggle can falsify; the nested row states the live destination instead. --- .../editor/markdown-preview-links.test.ts | 14 ++-- .../editor/markdown-preview-links.ts | 10 +-- .../BrowserLinkRoutingModifierSetting.tsx | 59 ++++++++++++++ .../src/components/settings/BrowserPane.tsx | 16 +++- .../browser-link-routing-modifier-copy.ts | 31 ++++++++ .../settings/browser-search.test.ts | 43 +++++++++++ .../src/components/settings/browser-search.ts | 38 +++++++++- .../edge-wrapped-terminal-http-links.test.ts | 44 +++++------ .../repro-8832-url-next-line.test.ts | 2 +- .../terminal-link-open-hints.test.ts | 47 ++++++++++++ .../terminal-pane/terminal-link-open-hints.ts | 15 +++- .../terminal-osc-link-routing.ts | 2 +- .../terminal-url-link-hit-testing.ts | 10 ++- .../terminal-pane/terminal-web-link-click.ts | 2 +- .../use-terminal-pane-lifecycle.ts | 9 ++- src/renderer/src/i18n/locales/en.json | 5 ++ src/renderer/src/i18n/locales/es.json | 5 ++ src/renderer/src/i18n/locales/ja.json | 5 ++ src/renderer/src/i18n/locales/ko.json | 5 ++ src/renderer/src/i18n/locales/zh.json | 5 ++ .../lib/http-link-modifier-routing.test.ts | 51 +++++++++++++ .../src/lib/http-link-routing.test.ts | 76 +++++++++++++++++++ src/renderer/src/lib/http-link-routing.ts | 44 +++++++++-- .../src/lib/pane-manager/pane-dom-creation.ts | 2 +- .../lib/pane-manager/pane-manager-types.ts | 2 + src/shared/constants.ts | 1 + src/shared/telemetry-events.ts | 1 + src/shared/types.ts | 2 + 28 files changed, 494 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx create mode 100644 src/renderer/src/components/settings/browser-link-routing-modifier-copy.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts create mode 100644 src/renderer/src/lib/http-link-modifier-routing.test.ts diff --git a/src/renderer/src/components/editor/markdown-preview-links.test.ts b/src/renderer/src/components/editor/markdown-preview-links.test.ts index 439541adda5..530e05c2bab 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.test.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.test.ts @@ -141,10 +141,10 @@ describe('isMarkdownPreviewSystemBrowserModifier', () => { }) describe('resolveMarkdownPreviewHttpOpenOptions', () => { - // forceSystemBrowser -> shell.openExternal (system default browser); - // worktreeId (no force) -> openHttpLink routes into the Orca browser per the - // openLinksInApp setting. See http-link-routing.test.ts for that mapping. - it('forces the system browser on Cmd+Shift-click on macOS', () => { + // modifierHeld -> openHttpLink resolves the escape hatch against the + // openLinksInApp / openLinksInAppModifierInverts pair; worktreeId (no + // modifier) routes per the setting. See http-link-routing.test.ts. + it('marks the modifier held on Cmd+Shift-click on macOS', () => { expect( resolveMarkdownPreviewHttpOpenOptions( { metaKey: true, ctrlKey: false, shiftKey: true }, @@ -152,10 +152,10 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => { 'wt-1', { kind: 'local' } ) - ).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } }) + ).toEqual({ worktreeId: 'wt-1', modifierHeld: true, sourceOwner: { kind: 'local' } }) }) - it('forces the system browser on Ctrl+Shift-click on Linux/Windows', () => { + it('marks the modifier held on Ctrl+Shift-click on Linux/Windows', () => { expect( resolveMarkdownPreviewHttpOpenOptions( { metaKey: false, ctrlKey: true, shiftKey: true }, @@ -163,7 +163,7 @@ describe('resolveMarkdownPreviewHttpOpenOptions', () => { 'wt-1', { kind: 'local' } ) - ).toEqual({ forceSystemBrowser: true, sourceOwner: { kind: 'local' } }) + ).toEqual({ worktreeId: 'wt-1', modifierHeld: true, sourceOwner: { kind: 'local' } }) }) it('routes a plain Cmd-click through the worktree so it can open in Orca', () => { diff --git a/src/renderer/src/components/editor/markdown-preview-links.ts b/src/renderer/src/components/editor/markdown-preview-links.ts index 0541ca0abf7..6062180ac28 100644 --- a/src/renderer/src/components/editor/markdown-preview-links.ts +++ b/src/renderer/src/components/editor/markdown-preview-links.ts @@ -113,10 +113,10 @@ export function isMarkdownPreviewSystemBrowserModifier( return event.shiftKey && (isMac ? event.metaKey : event.ctrlKey) } -// Why: Cmd/Ctrl+Shift-click is the escape hatch that forces the OS default -// browser; every other click routes through openHttpLink so the "open links in -// Orca" setting (and remote-runtime state) decides the destination. Mac uses -// metaKey, Linux/Windows use ctrlKey per AGENTS.md. +// Why: Cmd/Ctrl+Shift-click is the escape hatch; every click routes through +// openHttpLink so the "open links in Orca" setting (and remote-runtime state) +// decides the destination, with modifierHeld telling it the escape hatch fired. +// Mac uses metaKey, Linux/Windows use ctrlKey per AGENTS.md. export function resolveMarkdownPreviewHttpOpenOptions( event: Pick, isMac: boolean, @@ -124,7 +124,7 @@ export function resolveMarkdownPreviewHttpOpenOptions( sourceOwner: HttpLinkSourceOwner ): OpenHttpLinkOptions { if (isMarkdownPreviewSystemBrowserModifier(event, isMac)) { - return { forceSystemBrowser: true, sourceOwner } + return { worktreeId, modifierHeld: true, sourceOwner } } return { worktreeId, sourceOwner } } diff --git a/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx new file mode 100644 index 00000000000..106054a9d6a --- /dev/null +++ b/src/renderer/src/components/settings/BrowserLinkRoutingModifierSetting.tsx @@ -0,0 +1,59 @@ +import type { GlobalSettings } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' +import { SearchableSetting } from './SearchableSetting' +import { SettingsSwitchRow } from './SettingsFormControls' +import { + getLinkRoutingModifierDescription, + getLinkRoutingModifierTitle +} from './browser-link-routing-modifier-copy' + +type BrowserLinkRoutingModifierSettingProps = { + settings: Pick + isMac: boolean + updateSettings: (updates: Partial) => void +} + +export function BrowserLinkRoutingModifierSetting({ + settings, + isMac, + updateSettings +}: BrowserLinkRoutingModifierSettingProps): React.JSX.Element { + const openLinksInApp = settings.openLinksInApp === true + const title = getLinkRoutingModifierTitle(openLinksInApp) + const description = getLinkRoutingModifierDescription({ openLinksInApp, isMac }) + + return ( + + {/* Nested under Link Routing: this row only describes what its modifier does. */} +
+ + updateSettings({ + openLinksInAppModifierInverts: settings.openLinksInAppModifierInverts !== true + }) + } + ariaLabel={translate( + 'auto.components.settings.BrowserLinkRoutingModifierSetting.toggleLabel', + 'Toggle inverted link routing modifier' + )} + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 4a75f8fec97..c030cffd912 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -10,6 +10,7 @@ import { BrowserDefaultZoomSetting } from './BrowserDefaultZoomSetting' import { BrowserUseSetup } from './BrowserUsePane' import { BrowserSearchEngineSetting } from './BrowserSearchEngineSetting' import { BrowserLinkRoutingSetting } from './BrowserLinkRoutingSetting' +import { BrowserLinkRoutingModifierSetting } from './BrowserLinkRoutingModifierSetting' import { BrowserLocalhostWorktreeLabelsSetting } from './BrowserLocalhostWorktreeLabelsSetting' import { BrowserSessionCookiesSection } from './BrowserSessionCookiesSection' import { BrowserNewProfileDialog } from './BrowserNewProfileDialog' @@ -99,8 +100,11 @@ export function BrowserPane({ const showSearchEngine = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[1]]) const showDefaultZoom = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[2]]) const showLinkRouting = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[3]]) - const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[4]]) - const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]]) + const showLinkRoutingModifier = matchesSettingsSearch(searchQuery, [ + getBrowserPaneSearchEntries()[4] + ]) + const showLocalhostLabels = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[5]]) + const showCookies = matchesSettingsSearch(searchQuery, [getBrowserPaneSearchEntries()[6]]) const showBrowserUse = matchesSettingsSearch(searchQuery, getBrowserUsePaneSearchEntries()) const isMac = isMacUserAgent() const linkRoutingDescription = getBrowserLinkRoutingDescription({ isMac }) @@ -238,6 +242,14 @@ export function BrowserPane({ /> ) : null} + {showLinkRoutingModifier ? ( + + ) : null} + {showLocalhostLabels ? ( { it('uses macOS shortcut symbols for Link Routing copy and search metadata', () => { @@ -41,3 +45,42 @@ describe('browser settings search copy', () => { expect(linkRoutingEntry?.keywords).not.toContain('cmd') }) }) + +describe('browser link routing modifier copy', () => { + // Why: BrowserPane gates each row on getBrowserPaneSearchEntries()[n], so a + // reordered or inserted entry silently shows the wrong row for a search. + it('keeps the search entry order BrowserPane indexes by position', () => { + expect(getBrowserPaneSearchEntries({ isMac: true }).map((entry) => entry.title)).toEqual([ + 'Default Home Page', + 'Default Search Engine', + 'Default Zoom', + 'Link Routing', + 'Hold Shift to open in Orca', + 'Localhost Worktree Labels', + 'Session & Cookies' + ]) + }) + + it('names the destination the modifier actually reaches', () => { + expect(getLinkRoutingModifierTitle(false)).toBe('Hold Shift to open in Orca') + expect(getLinkRoutingModifierTitle(true)).toBe('Hold Shift to open in your web browser') + }) + + it('describes the modifier with the platform chord', () => { + expect(getLinkRoutingModifierDescription({ openLinksInApp: false, isMac: true })).toContain( + '⇧⌘' + ) + expect(getLinkRoutingModifierDescription({ openLinksInApp: false, isMac: false })).toContain( + 'Shift+Ctrl' + ) + }) + + it('points the description at Orca only when links currently open externally', () => { + expect(getLinkRoutingModifierDescription({ openLinksInApp: false, isMac: true })).toContain( + "Orca's built-in browser" + ) + expect(getLinkRoutingModifierDescription({ openLinksInApp: true, isMac: true })).toContain( + 'system browser' + ) + }) +}) diff --git a/src/renderer/src/components/settings/browser-search.ts b/src/renderer/src/components/settings/browser-search.ts index 4cd99f7fa77..f34fdb8a6de 100644 --- a/src/renderer/src/components/settings/browser-search.ts +++ b/src/renderer/src/components/settings/browser-search.ts @@ -1,6 +1,10 @@ import type { SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' import { translateSearchKeyword } from './settings-search-keywords' +import { + getLinkRoutingModifierDescription, + getLinkRoutingModifierTitle +} from './browser-link-routing-modifier-copy' type BrowserShortcutPlatform = { isMac: boolean @@ -16,8 +20,11 @@ export function getBrowserLinkRoutingShortcutLabel(platform: BrowserShortcutPlat return platform.isMac ? '⇧⌘-click' : 'Shift+Ctrl+click' } +// Why: the modifier sentence moved to the nested "Hold Shift to open in …" row — +// once that row can invert the routing, "always uses your system browser" is no +// longer true, and the child row states the live destination instead. export function getBrowserLinkRoutingDescription(platform: BrowserShortcutPlatform): string { - return `Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor. ${getBrowserLinkRoutingShortcutLabel(platform)} always uses your system browser.` + return `Open http(s) links in Orca's built-in browser — from the terminal, markdown, and the editor. ${getBrowserLinkRoutingShortcutLabel(platform)} opens a link the other way.` } export function getBrowserPaneSearchEntries( @@ -109,6 +116,35 @@ export function getBrowserPaneSearchEntries( ...translateSearchKeyword('auto.components.settings.browser.search.a7a07d5415', 'editor') ] }, + { + title: getLinkRoutingModifierTitle(false), + description: getLinkRoutingModifierDescription({ + openLinksInApp: false, + isMac: platform.isMac + }), + keywords: [ + ...translateSearchKeyword('auto.components.settings.browser.search.2d2d995c58', 'browser'), + ...translateSearchKeyword('auto.components.settings.browser.search.bea27bac4b', 'links'), + ...translateSearchKeyword('auto.components.settings.browser.search.90425d313c', 'shift'), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.routing', + 'routing' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.modifier', + 'modifier' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.invert', + 'invert' + ), + ...translateSearchKeyword( + 'auto.components.settings.browser.search.linkRoutingModifier.opposite', + 'opposite' + ), + platform.isMac ? 'cmd' : 'ctrl' + ] + }, { title: translate( 'auto.components.settings.browser.search.19ea5607cf', diff --git a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts index 9289f67b7b2..7f62f00ed80 100644 --- a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts +++ b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts @@ -76,7 +76,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -86,7 +86,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 3, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -111,7 +111,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -121,7 +121,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 1, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -136,7 +136,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -146,7 +146,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 2, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(false) expect(openUrlMock).not.toHaveBeenCalled() @@ -163,7 +163,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -173,7 +173,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 2, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(false) expect(openUrlMock).not.toHaveBeenCalled() @@ -189,7 +189,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -199,7 +199,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -218,7 +218,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -228,7 +228,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 20, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -245,7 +245,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 4, y: 2 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -268,7 +268,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 191, y: 1 }, cols, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -278,7 +278,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 10, y: rows.length }, cols, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -298,7 +298,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -308,7 +308,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 5, y: rowTexts.length }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -342,7 +342,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 5, y: rowTexts.length }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -361,7 +361,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -377,7 +377,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: firstRow.indexOf('https') + 8, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() @@ -394,7 +394,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 10, y: 1 }, COLS, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(false) expect(openUrlMock).not.toHaveBeenCalled() @@ -416,7 +416,7 @@ describe('edge-wrapped terminal HTTP links', () => { expect( openHttpLinkAtBufferPosition(buffer, { x: 10, y: 1 }, cols, { worktreeId: 'wt-1', - forceSystemBrowser: true + modifierHeld: true }) ).toBe(true) expect(openUrlMock).toHaveBeenCalledOnce() diff --git a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts index 14fbe14aa81..067fc46f94c 100644 --- a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts +++ b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts @@ -80,7 +80,7 @@ function openUrlAt( openUrlMock.mockReset() const opened = openHttpLinkAtBufferPosition(buffer, { x, y }, cols, { worktreeId: 'wt-repro-8832', - forceSystemBrowser: true + modifierHeld: true }) return { opened, url: openUrlMock.mock.calls[0]?.[0] as string | undefined } } diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts new file mode 100644 index 00000000000..4d802efed52 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getTerminalUrlOpenHint } from './terminal-link-open-hints' + +function stubPlatform(isMac: boolean): void { + vi.stubGlobal('navigator', { userAgent: isMac ? 'Mac OS X' : 'Windows NT 10.0' }) +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('getTerminalUrlOpenHint', () => { + it('keeps the system-browser wording by default', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint()).toBe('⌘+click to open or ⇧⌘+click for system browser') + }) + + it('keeps the system-browser wording when inverting is off', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: false })).toContain( + 'for system browser' + ) + }) + + // Why: with links already opening in Orca, inverting still lands on the system + // browser, so the hint must not promise Orca. + it('keeps the system-browser wording when inverting but links open in Orca', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint({ openLinksInApp: true, modifierInverts: true })).toContain( + 'for system browser' + ) + }) + + it('names Orca when inverting and links open externally', () => { + stubPlatform(true) + expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( + '⌘+click to open or ⇧⌘+click to open in Orca' + ) + }) + + it('uses the Ctrl chord off macOS', () => { + stubPlatform(false) + expect(getTerminalUrlOpenHint({ openLinksInApp: false, modifierInverts: true })).toBe( + 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + ) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts index 0d51669a6c6..3ecb4f9dbb4 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-open-hints.ts @@ -20,7 +20,20 @@ export function getTerminalHtmlFileOpenHint(): string { : 'Ctrl+click to open or Shift+Ctrl+click for default browser' } -export function getTerminalUrlOpenHint(): string { +export type TerminalUrlOpenHintOptions = { + openLinksInApp?: boolean + modifierInverts?: boolean +} + +// Why: with modifierInverts on, Shift no longer always means "system browser" — +// it means "the other one" — so the hint has to name the actual destination. +export function getTerminalUrlOpenHint(options: TerminalUrlOpenHintOptions = {}): string { + const invertsToOrca = options.modifierInverts === true && options.openLinksInApp !== true + if (invertsToOrca) { + return isMacPlatform() + ? '⌘+click to open or ⇧⌘+click to open in Orca' + : 'Ctrl+click to open or Shift+Ctrl+click to open in Orca' + } return isMacPlatform() ? '⌘+click to open or ⇧⌘+click for system browser' : 'Ctrl+click to open or Shift+Ctrl+click for system browser' diff --git a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts index ea1eff1b244..c51eedbd5c3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-osc-link-routing.ts @@ -81,7 +81,7 @@ export function handleOscLink( if (parsed.protocol === 'http:' || parsed.protocol === 'https:') { openTerminalHttpLink(parsed.toString(), { worktreeId: deps.worktreeId, - forceSystemBrowser: Boolean(event?.shiftKey), + modifierHeld: Boolean(event?.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) return true diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index 3a6e2c1f274..d38ce6d9378 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -11,7 +11,7 @@ import { buildWrappedLogicalLine, rangeForParsedFileLink } from './wrapped-termi type UrlLinkHitTestDeps = { worktreeId: string - forceSystemBrowser?: boolean + modifierHeld?: boolean requestOpenLinksInAppPreference?: TerminalLinkRoutingPreferenceRequester } @@ -225,7 +225,7 @@ export function installHttpLinkClickFallback( // never established, while defaultPrevented avoids duplicate opens. const opened = openHttpLinkAtTerminalMouseEvent(terminal, event, { worktreeId: deps.worktreeId, - forceSystemBrowser: event.shiftKey, + modifierHeld: event.shiftKey, requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) if (opened) { @@ -303,8 +303,10 @@ function rangeContainsBufferPosition( } export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { - if (deps.forceSystemBrowser) { - openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true }) + if (deps.modifierHeld) { + // Why: the modifier states a destination outright, so it also skips the + // one-time routing prompt; openHttpLink resolves which destination it means. + openHttpLink(url, { worktreeId: deps.worktreeId, modifierHeld: true }) return } diff --git a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts index c512cc84126..96b7d3ff003 100644 --- a/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts +++ b/src/renderer/src/components/terminal-pane/terminal-web-link-click.ts @@ -29,7 +29,7 @@ export function handleTerminalWebLinkClick( deps.terminal && openHttpLinkAtTerminalMouseEvent(deps.terminal, event, { worktreeId: deps.worktreeId, - forceSystemBrowser: Boolean(event.shiftKey), + modifierHeld: Boolean(event.shiftKey), requestOpenLinksInAppPreference: deps.requestOpenLinksInAppPreference }) ) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bdc8ea6300b..dce481daec7 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -778,7 +778,12 @@ export function useTerminalPaneLifecycle({ }) const fileOpenLinkHint = getTerminalFileOpenHint() - const urlOpenLinkHint = getTerminalUrlOpenHint() + // Why: read settingsRef at fire time so toggling link routing applies without recreating panes. + const getUrlOpenLinkHint = (): string => + getTerminalUrlOpenHint({ + openLinksInApp: settingsRef.current?.openLinksInApp === true, + modifierInverts: settingsRef.current?.openLinksInAppModifierInverts === true + }) const osc7UncHost = extractUncHost(startupCwd) let releaseWebviewDragPassthrough: (() => void) | null = null @@ -1100,6 +1105,7 @@ export function useTerminalPaneLifecycle({ hover: (_event, text) => { oscTooltipHoverToken += 1 const hoverToken = oscTooltipHoverToken + const urlOpenLinkHint = getUrlOpenLinkHint() pane.linkTooltip.textContent = `${text} (${urlOpenLinkHint})` pane.linkTooltip.style.display = '' void formatTerminalUrlTooltip(text, urlOpenLinkHint).then((nextText) => { @@ -1405,6 +1411,7 @@ export function useTerminalPaneLifecycle({ requestOpenLinksInAppPreference }) }, + linkOpenHint: getUrlOpenLinkHint, formatLinkTooltip: (url, openLinkHint) => formatTerminalUrlTooltip(url, openLinkHint), // Why: hidden panes stay mounted so PTYs survive navigation, but their WebGL contexts drain Chromium's budget and can blank visible panes. initialRenderingSuspended: !isVisibleRef.current, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index a8e950f092d..8d2a0486a2b 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -9745,6 +9745,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 23b52f4463b..5d604009e32 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 145b4d5792b..a78ef3bd957 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8522609d008..ec7196f8a16 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index efc3b530c02..355bffb9f2b 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9722,6 +9722,11 @@ "serviceManagerHelp": "Update Orca through the service manager that starts this server.", "unpackedHelp": "Development builds must be updated from their source checkout.", "legacyHelp": "Update this server manually once to enable remote updates." + }, + "BrowserLinkRoutingModifierSetting": { + "toggleLabel": "Toggle inverted link routing modifier", + "titleSystem": "Hold Shift to open in your web browser", + "titleOrca": "Hold Shift to open in Orca" } }, "right": { diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts new file mode 100644 index 00000000000..b37a900cde3 --- /dev/null +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { resolveModifierRouting } from './http-link-routing' + +describe('resolveModifierRouting', () => { + it('is inert without the modifier regardless of settings', () => { + for (const openLinksInApp of [true, false]) { + for (const inverts of [true, false]) { + expect(resolveModifierRouting(false, openLinksInApp, inverts)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: false + }) + } + } + }) + + // Why: the setting ships off, so the historical one-way escape hatch must be + // byte-for-byte unchanged for every existing user. + it('always forces the system browser when inverting is off', () => { + expect(resolveModifierRouting(true, true, false)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: true + }) + expect(resolveModifierRouting(true, false, false)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: true + }) + }) + + it('still reaches the system browser when inverting and links open in Orca', () => { + expect(resolveModifierRouting(true, true, true)).toEqual({ + wantsOrca: false, + wantsSystemBrowser: true + }) + }) + + it('reaches Orca when inverting and links open in the system browser', () => { + expect(resolveModifierRouting(true, false, true)).toEqual({ + wantsOrca: true, + wantsSystemBrowser: false + }) + }) + + it('only diverges from the legacy behavior when links open externally', () => { + expect(resolveModifierRouting(true, true, true)).toEqual( + resolveModifierRouting(true, true, false) + ) + expect(resolveModifierRouting(true, false, true)).not.toEqual( + resolveModifierRouting(true, false, false) + ) + }) +}) diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 56ddc9b6e27..86c3bc18c54 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -16,6 +16,7 @@ const storeState = { settings: undefined as | { openLinksInApp?: boolean + openLinksInAppModifierInverts?: boolean openLinksInAppPreferencePrompted?: boolean activeRuntimeEnvironmentId?: string | null localhostWorktreeLabelsEnabled?: boolean @@ -380,3 +381,78 @@ describe('openHttpLink', () => { expect(registerLocalhostLabelMock).not.toHaveBeenCalled() }) }) + +describe('openHttpLink modifier routing', () => { + it('forces the system browser when inverting is off and links open in Orca', () => { + storeState.settings = { openLinksInApp: true, openLinksInAppModifierInverts: false } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + // Why: this is the pre-existing dead gesture — Shift already meant "system + // browser", which is where the link was going anyway. + it('stays on the system browser when inverting is off and links open externally', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: false } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('opens in Orca when inverting is on and links open externally', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(setActiveWorktreeMock).toHaveBeenCalledWith('wt-1') + expect(createBrowserTabMock).toHaveBeenCalledWith('wt-1', 'https://example.com/', { + activate: true + }) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it('opens in the system browser when inverting is on and links open in Orca', () => { + storeState.settings = { openLinksInApp: true, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', modifierHeld: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('leaves unmodified clicks on the setting', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1' }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + // Why: remote-owned links must never land in an Orca tab that cannot reach them. + it('never routes a remote source into Orca even when inverting', () => { + storeState.settings = { openLinksInApp: false, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { + worktreeId: 'wt-1', + modifierHeld: true, + sourceOwner: { kind: 'ssh', connectionId: 'conn-1' } + }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) + + it('keeps forceSystemBrowser unconditional', () => { + storeState.settings = { openLinksInApp: true, openLinksInAppModifierInverts: true } + + openHttpLink('https://example.com/', { worktreeId: 'wt-1', forceSystemBrowser: true }) + + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/') + expect(createBrowserTabMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index e3a34faa463..06cc0d5235e 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -8,7 +8,10 @@ import type { WorkspacePort, WorkspacePortScanResult } from '../../../shared/wor export type OpenHttpLinkOptions = { worktreeId?: string | null + /** Unconditional: always use the system browser regardless of settings. */ forceSystemBrowser?: boolean + /** The Shift escape-hatch modifier was held; resolveModifierRouting decides what it means. */ + modifierHeld?: boolean sourceOwner?: HttpLinkSourceOwner } @@ -22,7 +25,10 @@ type StoreAccessor = () => { settings?: Partial< Pick< GlobalSettings, - 'openLinksInApp' | 'activeRuntimeEnvironmentId' | 'localhostWorktreeLabelsEnabled' + | 'openLinksInApp' + | 'openLinksInAppModifierInverts' + | 'activeRuntimeEnvironmentId' + | 'localhostWorktreeLabelsEnabled' > > | null setActiveWorktree: (worktreeId: string) => void @@ -61,21 +67,49 @@ export function registerHttpLinkStoreAccessor(fn: StoreAccessor): void { // Scope: http(s) URLs only. file: URIs and in-worktree markdown targets are // owned by resolveMarkdownLinkTarget and must stay on that path — this helper // is only invoked on target.kind === 'external' (and for the terminal's http -// branch). Shift+Cmd/Ctrl is the escape hatch: callers pass forceSystemBrowser -// to bypass the setting entirely. +// branch). Shift+Cmd/Ctrl is the escape hatch: click handlers pass modifierHeld +// so resolveModifierRouting applies it; forceSystemBrowser stays reserved for +// callers that must bypass the setting unconditionally. +/** + * Resolves what the Shift modifier means for one click. Historically it always + * forced the system browser, which is a no-op when that is already the default; + * openLinksInAppModifierInverts makes it flip whichever way Link Routing points + * so the other destination is always one click away. + */ +export function resolveModifierRouting( + modifierHeld: boolean, + openLinksInApp: boolean, + modifierInverts: boolean +): { wantsOrca: boolean; wantsSystemBrowser: boolean } { + if (!modifierHeld) { + return { wantsOrca: false, wantsSystemBrowser: false } + } + if (!modifierInverts) { + return { wantsOrca: false, wantsSystemBrowser: true } + } + return { wantsOrca: !openLinksInApp, wantsSystemBrowser: openLinksInApp } +} + export function openHttpLink(url: string, opts: OpenHttpLinkOptions = {}): void { - const { worktreeId, forceSystemBrowser, sourceOwner } = opts + const { worktreeId, forceSystemBrowser, modifierHeld, sourceOwner } = opts if (sourceOwner?.kind === 'unknown') { return } const state = storeAccessor?.() const remoteRuntimeActive = Boolean(state?.settings?.activeRuntimeEnvironmentId?.trim()) const sourceIsLocal = sourceOwner ? sourceOwner.kind === 'local' : !remoteRuntimeActive + const openLinksInApp = state?.settings?.openLinksInApp === true + const modifier = resolveModifierRouting( + Boolean(modifierHeld), + openLinksInApp, + state?.settings?.openLinksInAppModifierInverts === true + ) const routeToOrca = sourceIsLocal && !forceSystemBrowser && + !modifier.wantsSystemBrowser && Boolean(worktreeId) && - state?.settings?.openLinksInApp === true + (openLinksInApp || modifier.wantsOrca) if (routeToOrca && worktreeId && state) { // Why: http clicks from inside a worktree should not push a worktree-switch diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index 7c2fa26fab6..59fde2ea8de 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -60,7 +60,6 @@ export function createPaneDOM( const fitAddon = new FitAddon() const searchAddon = new SearchAddon() const unicode11Addon = new Unicode11Addon() - const openLinkHint = getTerminalUrlOpenHint() // Why: async tooltip formatting can resolve after hover changes, so stale // results must not overwrite the tooltip for the currently hovered link. let linkTooltipHoverToken = 0 @@ -83,6 +82,7 @@ export function createPaneDOM( if (uri) { linkTooltipHoverToken += 1 const hoverToken = linkTooltipHoverToken + const openLinkHint = options.linkOpenHint?.() ?? getTerminalUrlOpenHint() linkTooltip.textContent = defaultLinkTooltipText(uri, openLinkHint) linkTooltip.style.display = '' const formatted = options.formatLinkTooltip?.(uri, openLinkHint) diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 2c1fd67de6d..fb53097c1f8 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -60,6 +60,8 @@ export type PaneManagerOptions = { terminalOptions?: (paneId: number) => Partial terminalTuiScrollSensitivity?: () => number | undefined onLinkClick?: (event: MouseEvent | undefined, url: string) => void + /** Resolved per hover so link-routing setting changes apply without recreating panes. */ + linkOpenHint?: () => string formatLinkTooltip?: ( url: string, openLinkHint: string diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 7c11e607d96..ecbfd7594b7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -263,6 +263,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { openLinksInApp: false, localhostWorktreeLabelsEnabled: false, openLinksInAppPreferencePrompted: false, + openLinksInAppModifierInverts: false, openAgentTabsInChatByDefault: false, experimentalNativeChat: false, nativeChatSessionOptions: {}, diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 9cbfaac20e2..f477f67a0e4 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -241,6 +241,7 @@ type BooleanGlobalSettingsKey = { export const SETTINGS_CHANGED_WHITELIST = [ 'editorAutoSave', 'openLinksInApp', + 'openLinksInAppModifierInverts', 'experimentalMobile', 'experimentalPet', 'experimentalNativeChat', diff --git a/src/shared/types.ts b/src/shared/types.ts index 2983cbf32ac..b3bcd17dc4c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2764,6 +2764,8 @@ export type GlobalSettings = { localhostWorktreeLabelsEnabled?: boolean /** Tracks the one-time first-use prompt for terminal link routing (avoid silently changing where links open). */ openLinksInAppPreferencePrompted: boolean + /** Opt-in: Shift+modifier click inverts openLinksInApp instead of always forcing the system browser. Off keeps the historical one-way escape hatch. */ + openLinksInAppModifierInverts?: boolean /** Opt-in: open new coding-agent tabs in native chat instead of the raw terminal; optional for legacy settings. */ openAgentTabsInChatByDefault?: boolean /** Experimental native chat surface for Claude/Codex sessions; off by default. */