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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import Foundation

/// Settings under the dotted-id prefix `browser.*`.
public struct BrowserCatalogSection: SettingCatalogSection {
/// The dwell range accepted before a terminal link preview appears.
public static let terminalLinkPreviewHoverDelayMillisecondsRange = 200...2_000

/// The default dwell balances deliberate intent against a responsive preview.
public static let defaultTerminalLinkPreviewHoverDelayMilliseconds = 650

public let defaultSearchEngine = DefaultsKey<BrowserSearchEngine>(
id: "browser.defaultSearchEngine",
defaultValue: BrowserSearchSettingsStore.defaultSearchEngine,
Expand Down Expand Up @@ -58,6 +64,12 @@ public struct BrowserCatalogSection: SettingCatalogSection {
userDefaultsKey: "browserOpenTerminalLinksInCmuxBrowser"
)

public let terminalLinkPreviewHoverDelayMilliseconds = DefaultsKey<Int>(
id: "browser.terminalLinkPreviewHoverDelayMilliseconds",
defaultValue: Self.defaultTerminalLinkPreviewHoverDelayMilliseconds,
userDefaultsKey: "browserTerminalLinkPreviewHoverDelayMilliseconds"
)

public let interceptTerminalOpenCommandInCmuxBrowser = DefaultsKey<Bool>(
id: "browser.interceptTerminalOpenCommandInCmuxBrowser",
defaultValue: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,13 @@ extension Array where Element == CuratedSettingEntry {
synonyms: String(localized: "settings.search.alias.setting.browser.ask-where-to-save-downloads", defaultValue: "browser.askWhereToSaveDownloads downloads save panel folder attachments files pdf gmail")
),
.init(section: .browser, id: "terminal-links", title: "Open Terminal Links in cmux Browser", synonyms: "browser.openTerminalLinksInCmuxBrowser click url terminal links open in browser href"),
.init(
section: .browser,
id: "terminal-link-preview-delay",
title: String(localized: "settings.browser.terminalLinkPreviewDelay", defaultValue: "Terminal Link Preview Delay"),
detailText: String(localized: "settings.browser.terminalLinkPreviewDelay.subtitle", defaultValue: "How long the pointer must rest on a terminal link before its live page preview appears."),
synonyms: String(localized: "settings.search.alias.setting.browser.terminal-link-preview-delay", defaultValue: "browser.terminalLinkPreviewHoverDelayMilliseconds terminal url link hover preview dwell delay milliseconds thumbnail web page")
),
.init(section: .browser, id: "intercept-open", title: "Intercept open http(s) in Terminal", synonyms: "browser.interceptTerminalOpenCommandInCmuxBrowser open command http https url terminal intercept"),
.init(section: .browser, id: "host-whitelist", title: "Hosts to Open in Embedded Browser", synonyms: "browser.hostsToOpenInEmbeddedBrowser allowlist whitelist host wildcard domain embedded browser"),
.init(section: .browser, id: "external-patterns", title: "URLs to Always Open Externally", synonyms: "browser.urlsToAlwaysOpenExternally denylist blocklist regex rules external default browser"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import SwiftUI
/// row-for-row inside a single `SettingsCard`: Enable cmux Browser,
/// Default Search Engine, conditional Custom Search Engine fields,
/// Show Search Suggestions, Browser Theme, Browser Memory Saver +
/// Memory Saver Delay, Open Terminal Links / Intercept open,
/// Memory Saver Delay, Open Terminal Links + preview delay / Intercept open,
/// conditional Hosts / External Patterns text editors, HTTP Hosts
/// Allowed in Embedded Browser editor, Import Browser Data
/// subsection, React Grab Version, Browsing History.
Expand All @@ -27,6 +27,7 @@ public struct BrowserSection: View {
@State private var discardDelay: DefaultsValueModel<Double>
@State private var askWhereToSaveDownloads: DefaultsValueModel<Bool>
@State private var openTermLinks: DefaultsValueModel<Bool>
@State private var terminalLinkPreviewDelay: DefaultsValueModel<Int>
@State private var interceptOpen: DefaultsValueModel<Bool>
@State private var hosts: DefaultsValueModel<String>
@State private var external: DefaultsValueModel<String>
Expand Down Expand Up @@ -58,6 +59,7 @@ public struct BrowserSection: View {
_discardDelay = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hiddenWebViewDiscardDelaySeconds))
_askWhereToSaveDownloads = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.askWhereToSaveDownloads))
_openTermLinks = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.openTerminalLinksInCmuxBrowser))
_terminalLinkPreviewDelay = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.terminalLinkPreviewHoverDelayMilliseconds))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Expect the settings model or JSON loader to normalize this key to 200...2_000.
rg -n -C 8 \
  'DefaultsValueModel|terminalLinkPreviewHoverDelayMillisecondsRange|browser\.terminalLinkPreviewHoverDelayMilliseconds' \
  --glob '*.{swift,json}' Packages Sources cmuxTests web

Repository: manaflow-ai/cmux

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Candidate files =="
fd -a 'BrowserSection.swift|DefaultsValueModel.swift|UserDefaultsSettingsStore.swift|JSONConfigStore.swift|KeyboardShortcutSettingsFileStore.swift|BrowserPanel.swift|BrowserCatalogSection.swift' . | sed 's#^\./##' | sort

echo
echo "== BrowserSection.swift relevant sections =="
file="$(fd 'BrowserSection.swift$' . | head -n1)"
if [ -n "$file" ]; then
  wc -l "$file"
  sed -n '1,120p' "$file" | cat -n
  sed -n '220,290p' "$file" | cat -n
fi

echo
echo "== DefaultsValueModel definitions/usages around model =="
rg -n -C 6 'struct DefaultsValueModel|class DefaultsValueModel|actor DefaultsValueModel|typealias DefaultsValueModel|extension DefaultsValueModel|DefaultsValueModel<' --glob '*.swift' . | head -n 220

echo
echo "== DefaultsValueModel files =="
for f in $(rg -l 'struct DefaultsValueModel|class DefaultsValueModel|actor DefaultsValueModel' --glob '*.swift' . | head -n 10); do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  sed -n '1,240p' "$f" | cat -n
done

echo
echo "== BrowserPanel runtime accessor =="
bpf="$(fd 'BrowserPanel.swift$' Sources/Panels | head -n1)"
if [ -n "$bpf" ]; then
  sed -n '450,490p' "$bpf" | cat -n
fi

echo
echo "== Store initialization around _terminalLinkPreviewDelay line =="
if [ -n "$file" ]; then
  rg -n -C 10 'terminalLinkPreviewDelay|terminalLinkPreviewHoverDelayMilliseconds' "$file"
fi

Repository: manaflow-ai/cmux

Length of output: 50372


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== DefaultsValueModel.swift relevant implementation =="
file="$(fd 'DefaultsValueModel.swift$' . | head -n1)"
sed -n '1,220p' "$file" | cat -n
sed -n '220,307p' "$file" | cat -n

echo
echo "== UserDefaultsSettingsStore initialization/validation/key read paths =="
ust="$(fd 'UserDefaultsSettingsStore.swift$' Packages/macOS/CmuxSettings/Sources | head -n1)"
sed -n '1,260p' "$ust" | cat -n
echo "matches in UserDefaultsSettingsStore.swift for setting keys / reads"
rg -n -C 4 'initialValue|set\(|reset\(|reconcile|terminalLinkPreviewHoverDelayMilliseconds|DefaultsKey|class DefaultsKey|struct DefaultsKey' "$ust"

echo
echo "== JSONConfigStore snippets for relevant validation path =="
jcs="$(fd 'JSONConfigStore.swift$' Packages/macOS/CmuxSettings/Sources | head -n1)"
rg -n -C 6 'terminalLinkPreviewHoverDelayMilliseconds|defaultTerminalLinkPreviewHoverDelayMilliseconds|logInvalid|managedUserDefaults|jsonInt' "$jcs"

echo
echo "== Static verifier: bounds used by runtime/import paths =="
python3 - <<'PY'
from pathlib import Path
files = list(Path('.').rglob('*'))
for name in [Path('Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift'),
             Path('Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift'),
             Path('Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/JSONConfigStore.swift'),
             Path('Sources/Panels/BrowserPanel.swift')]:
    print(f"\n--- {name} ---")
    text = name.read_text()
    for needle in ['terminalLinkPreviewHoverDelayMillisecondsRange', 'terminalLinkPreviewHoverDelayMilliseconds',
                   'defaultTerminalLinkPreviewHoverDelayMilliseconds', '200...2_000', 'maximum', 'minimum']:
        print(f"{needle}: {text.count(needle)}")

print("\n--- Behavioral invariant from source text ---")
checks = [
    ('BrowserSection reads unbounded DefaultsValueModel<Int>', Path('Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift').read_text().count('DefaultsValueModel<Int>') >= 2),
    ('JSON import rejects out-of-range integer and does not persist it', 'guard BrowserCatalogSection.terminalLinkPreviewHoverDelayMillisecondsRange.contains(value) else {\n            logInvalid("browser.terminalLinkPreviewHoverDelayMilliseconds", sourcePath: sourcePath)\n            return' in Path('Sources/KeyboardShortcutSettingsFileStore.swift').read_text()),
]
for label, ok in checks:
    print(f"{label}: {ok}")
PY

Repository: manaflow-ai/cmux

Length of output: 33739


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd 'DefaultsValueModel.swift$' . | head -n1)"
ust="$(fd 'UserDefaultsSettingsStore.swift$' Packages/macOS/CmuxSettings/Sources | head -n1)"
jcs="$(fd 'JSONConfigStore.swift$' Packages/macOS/CmuxSettings/Sources | head -n1)"

echo "== DefaultsValueModel remaining implementation =="
sed -n '220,307p' "$file" | cat -n

echo
echo "== UserDefaultsSettingsStorage class references =="
rg -n -C 5 'class|struct|enum UserDefaultsSettingsStorage|func value\\(|func set\\(' "$ust" "$(fd 'UserDefaultsSettingsStorage.swift$' Packages/macOS/CmuxSettings/Sources | head -n1)" || true

echo
echo "== JSONConfigStore validation imports for terminalLinkPreviewHoverDelayMilliseconds =="
rg -n -C 8 'terminalLinkPreviewHoverDelayMilliseconds|defaultTerminalLinkPreviewHoverDelayMilliseconds|jsonInt\\(|logInvalid\\(|managedUserDefaults' "$jcs" "Sources/KeyboardShortcutSettingsFileStore.swift"

echo
echo "== Static checks =="
python3 - <<'PY'
from pathlib import Path

models = [
    'Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift',
    'Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift',
    'Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift',
    'Sources/KeyboardShortcutSettingsFileStore.swift',
    'Sources/Panels/BrowserPanel.swift',
]

for path in models:
    text = Path(path).read_text()
    print(f"\n--- {path} ({len(text.splitlines())} lines) ---")
    for needle in [
        'set(_ value: Value, for key: DefaultsKey<Value>'),
        'storage.value(for: key)',
        'storage.set(value, for: key)',
        'terminalLinkPreviewHoverDelayMillisecondsRange',
        'terminalLinkPreviewHoverDelayMilliseconds',
        'maximum',
        'minimum',
        'default',
    ]:
        lines = [i for i, line in enumerate(text.splitlines(), 1) if needle in line]
        if lines:
            print(f"{needle}: {lines[:10]}")
        else:
            print(f"{needle}: missing")

print("\n--- Relevant source assertions ---")
json_path = Path('Sources/KeyboardShortcutSettingsFileStore.swift')
json_text = json_path.read_text()
print("JSON import path rejects unbounded terminalLinkPreviewHoverDelayMilliseconds before persisting:",
      'guard BrowserCatalogSection.terminalLinkPreviewHoverDelayMillisecondsRange.contains(value) else {\n            logInvalid("browser.terminalLinkPreviewHoverDelayMilliseconds", sourcePath: sourcePath)\n            return\n        }' in json_text)

browserpath = 'Sources/Panels/BrowserPanel.swift'
print("Preview runtime clamps values outside range to 650:",
      'let value = stored.intValue\n        return BrowserCatalogSection.terminalLinkPreviewHoverDelayMillisecondsRange.contains(value)\n            ? value\n            : defaultTerminalLinkPreviewHoverDelayMilliseconds' in Path(browserpath).read_text())
PY

Repository: manaflow-ai/cmux

Length of output: 4878


Use one validated accessor for the preview delay.

JSONConfigStore rejects out-of-range browser.terminalLinkPreviewHoverDelayMilliseconds, but DefaultsValueModel<Int> seeds current from the raw UserDefaults value and BrowserSection writes it back through an unbounded store path. A stale or manually inserted value can appear in the settings UI, while BrowserPanel.swift rejects values outside 200...2_000. Add the shared range clamp/rejection to the settings binding or normalize it at a single validated accessor.
[low_effort_and_medium_reward]

🤖 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/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift`
at line 62, Update the _terminalLinkPreviewDelay accessor in BrowserSection so
it uses a single validated value constrained to the same 200...2,000 range
enforced by BrowserPanel and JSONConfigStore. Ensure both the initial
UserDefaults value and subsequent writes are normalized or rejected through that
accessor, preventing DefaultsValueModel<Int> from exposing or persisting
out-of-range values.

_interceptOpen = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.interceptTerminalOpenCommandInCmuxBrowser))
_hosts = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hostsToOpenInEmbeddedBrowser))
_external = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.urlsToAlwaysOpenExternally))
Expand Down Expand Up @@ -85,7 +87,7 @@ public struct BrowserSection: View {
Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {}
} message: {
Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar."))
}.task { startSettingsObservation([disabled, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, askWhereToSaveDownloads, openTermLinks, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) }
}.task { startSettingsObservation([disabled, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, askWhereToSaveDownloads, openTermLinks, terminalLinkPreviewDelay, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) }
}

@ViewBuilder
Expand Down Expand Up @@ -241,6 +243,34 @@ public struct BrowserSection: View {
}
SettingsCardDivider()

// Terminal Link Preview Delay
SettingsCardRow(
configurationReview: .json("browser.terminalLinkPreviewHoverDelayMilliseconds"),
String(localized: "settings.browser.terminalLinkPreviewDelay", defaultValue: "Terminal Link Preview Delay"),
subtitle: String(localized: "settings.browser.terminalLinkPreviewDelay.subtitle", defaultValue: "How long the pointer must rest on a terminal link before its live page preview appears."),
controlWidth: Self.columnWidth
) {
HStack(spacing: 8) {
Text(formatTerminalLinkPreviewDelay(terminalLinkPreviewDelay.current))
.cmuxFont(.body, design: .monospaced)
.monospacedDigit()
.frame(width: 64, alignment: .trailing)
Stepper(
"",
value: Binding(
get: { terminalLinkPreviewDelay.current },
set: { terminalLinkPreviewDelay.set($0) }
),
in: BrowserCatalogSection.terminalLinkPreviewHoverDelayMillisecondsRange,
step: 50
)
.labelsHidden()
}
.disabled(disabled.current || !openTermLinks.current)
.accessibilityIdentifier("SettingsBrowserTerminalLinkPreviewDelayStepper")
}
SettingsCardDivider()

// Intercept open
SettingsCardRow(
configurationReview: .json("browser.interceptTerminalOpenCommandInCmuxBrowser"),
Expand Down Expand Up @@ -564,4 +594,12 @@ public struct BrowserSection: View {
let format = String(localized: "settings.browser.hiddenWebViewDiscardDelay.minutesSeconds", defaultValue: "%lldm %llds")
return String.localizedStringWithFormat(format, Int64(total / 60), Int64(total % 60))
}

private func formatTerminalLinkPreviewDelay(_ milliseconds: Int) -> String {
let format = String(
localized: "settings.browser.terminalLinkPreviewDelay.milliseconds",
defaultValue: "%lld ms"
)
return String.localizedStringWithFormat(format, Int64(milliseconds))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ struct SettingsRowAnchorResolutionTests {
"browser.hostsToOpenInEmbeddedBrowser",
"browser.interceptTerminalOpenCommandInCmuxBrowser",
"browser.openTerminalLinksInCmuxBrowser",
"browser.terminalLinkPreviewHoverDelayMilliseconds",
"browser.reactGrabVersion",
"browser.showSearchSuggestions",
"browser.theme",
Expand Down
68 changes: 68 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -170589,6 +170589,57 @@
}
}
},
"settings.browser.terminalLinkPreviewDelay": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Terminal Link Preview Delay"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ターミナルリンクのプレビュー遅延"
}
}
}
},
"settings.browser.terminalLinkPreviewDelay.milliseconds": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "%lld ms"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "%lldミリ秒"
}
}
}
},
"settings.browser.terminalLinkPreviewDelay.subtitle": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "How long the pointer must rest on a terminal link before its live page preview appears."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ターミナルリンクにポインタを置いてからライブページのプレビューが表示されるまでの時間です。"
}
}
}
},
Comment on lines +170592 to +170642

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json
from pathlib import Path

path = Path("Resources/Localizable.xcstrings")
catalog = json.loads(path.read_text())
strings = catalog["strings"]

keys = [
    "settings.browser.terminalLinkPreviewDelay",
    "settings.browser.terminalLinkPreviewDelay.milliseconds",
    "settings.browser.terminalLinkPreviewDelay.subtitle",
    "settings.search.alias.setting.browser.terminal-link-preview-delay",
]

catalog_locales = sorted({
    locale
    for entry in strings.values()
    for locale in entry.get("localizations", {})
})

for key in keys:
    missing = [
        locale
        for locale in catalog_locales
        if locale not in strings[key].get("localizations", {})
    ]
    if missing:
        print(f"{key}: missing {', '.join(missing)}")
PY

Repository: manaflow-ai/cmux

Length of output: 740


Add missing localization entries for the new terminal preview keys.

Resources/Localizable.xcstrings contains additional supported locales beyond en and ja, but these keys still lack entries for ar, bs, da, de, es, fr, it, km, ko, nb, pl, pt-BR, ru, th, tr, uk, zh-Hans, and zh-Hant. Add stringUnit translations for each changed key family.

🤖 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 `@Resources/Localizable.xcstrings` around lines 170592 - 170642, Add
localization entries for ar, bs, da, de, es, fr, it, km, ko, nb, pl, pt-BR, ru,
th, tr, uk, zh-Hans, and zh-Hant under each terminalLinkPreviewDelay key shown,
including the base, milliseconds, and subtitle entries. Preserve the existing en
and ja translations and match the surrounding Localizable.xcstrings stringUnit
structure.

Source: Path instructions

"settings.browser.theme": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -188035,6 +188086,23 @@
}
}
},
"settings.search.alias.setting.browser.terminal-link-preview-delay": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "browser.terminalLinkPreviewHoverDelayMilliseconds terminal url link hover preview dwell delay milliseconds thumbnail web page"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "browser.terminalLinkPreviewHoverDelayMilliseconds ターミナル URL リンク ホバー プレビュー 待機 遅延 ミリ秒 サムネイル ウェブページ"
}
}
}
},
"settings.search.alias.setting.browser.terminal-links": {
"extractionState": "manual",
"localizations": {
Expand Down
33 changes: 28 additions & 5 deletions Sources/App/ForeignFirstResponderPolicy.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
import AppKit

/// Marks an interactive view subtree whose active responder must keep keyboard
/// focus even while a terminal remains the selected panel. This is intentionally
/// a shared opt-in instead of a preview-specific check so any embedded surface
/// can participate without teaching terminal focus repair about its concrete type.
protocol TerminalFocusRepairExclusionRegion: AnyObject {
var excludesTerminalFocusRepair: Bool { get }
}

@MainActor
private func isInsideTerminalFocusRepairExclusionRegion(_ responder: NSResponder) -> Bool {
guard var view = responder as? NSView else { return false }
while true {
if let region = view as? TerminalFocusRepairExclusionRegion,
region.excludesTerminalFocusRepair {
return true
}
guard let superview = view.superview else { return false }
view = superview
}
}

/// Whether an active terminal surface should *yield* to `firstResponder` instead of taking first
/// responder itself when reconciling focus (used by `GhosttySurfaceScrollView.ensureFocus` and the
/// find-overlay focus apply).
///
/// A terminal yields only to a *legitimate* focus owner: a focused text editor (`NSText` field
/// editor) or a right-sidebar / dock / feed host whose window is either `window` or an attached
/// child of `window`. AppKit hosts popover field editors in an `_NSPopoverWindow` child while
/// leaving the main window as key, so child-window membership is the designed arrangement for
/// popover typing.
/// editor), a responder inside a ``TerminalFocusRepairExclusionRegion``, or a right-sidebar / dock /
/// feed host whose window is either `window` or an attached child of `window`. AppKit hosts popover
/// field editors in an `_NSPopoverWindow` child while leaving the main window as key, so child-window
/// membership is the designed arrangement for popover typing.
///
/// cmux hosts terminal surfaces through a portal that reparents views between windows; a focus owner
/// can be reparented out of a window without resigning, leaving `window.firstResponder` pointing at
Expand Down Expand Up @@ -44,7 +65,9 @@ func shouldRespectForeignFirstResponder(
// belongs to this window and must not block the terminal from reclaiming first responder.
guard let responderWindow = (firstResponder as? NSView)?.window,
window.containsAttachedWindow(responderWindow) else { return false }
return firstResponder is NSText || isRightSidebarOwner(firstResponder)
return firstResponder is NSText
|| isInsideTerminalFocusRepairExclusionRegion(firstResponder)
|| isRightSidebarOwner(firstResponder)
}

fileprivate extension NSWindow {
Expand Down
1 change: 1 addition & 0 deletions Sources/CmuxSettingsJSONPathSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,7 @@ extension CmuxSettingsFileStore {
"browser.hiddenWebViewDiscardDelaySeconds",
"browser.askWhereToSaveDownloads",
"browser.openTerminalLinksInCmuxBrowser",
"browser.terminalLinkPreviewHoverDelayMilliseconds",
"browser.interceptTerminalOpenCommandInCmuxBrowser",
"browser.hostsToOpenInEmbeddedBrowser",
"browser.urlsToAlwaysOpenExternally",
Expand Down
15 changes: 14 additions & 1 deletion Sources/DockSplitStore+TerminalLinkOpening.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ extension DockSplitStore: TerminalLinkOpenContainer {
detachedSurfaceTransfersByPanelId[sourcePanelId]?.isRemoteTerminal == true
}

func terminalLinkBrowserProfileID(for sourcePanelId: UUID) -> UUID? {
guard paneId(forPanelId: sourcePanelId) != nil else { return nil }
return BrowserPanel.resolvedProfileID(requested: nil)
}

func deferTerminalFileLinkOpen(
sourcePanelId _: UUID,
filePath _: String,
Expand All @@ -26,6 +31,12 @@ extension DockSplitStore: TerminalLinkOpenContainer {

func openTerminalBrowserLink(url: URL, sourcePanelId: UUID) -> Bool {
guard let sourcePane = paneId(forPanelId: sourcePanelId) else { return false }
let intendedProfileID = terminalLinkBrowserProfileID(for: sourcePanelId)
let preferredProfileID = intendedProfileID.flatMap { profileID in
BrowserPrewarmedWebViewPool.shared.hasEntry(url: url, profileID: profileID)
? profileID
: nil
}
if let targetPane = BrowserRightSidePaneResolver().preferredPane(
from: sourcePane,
in: bonsplitController
Expand All @@ -34,7 +45,8 @@ extension DockSplitStore: TerminalLinkOpenContainer {
kind: .browser,
inPane: targetPane,
url: url,
focus: true
focus: true,
preferredProfileID: preferredProfileID
) != nil
}
return newSplit(
Expand All @@ -43,6 +55,7 @@ extension DockSplitStore: TerminalLinkOpenContainer {
insertFirst: false,
sourcePanelId: sourcePanelId,
url: url,
preferredProfileID: preferredProfileID,
focus: true
) != nil
}
Expand Down
23 changes: 23 additions & 0 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4120,6 +4120,9 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
} else {
surface.reconcileAttachedWindowIfNeeded(for: self)
}
if let ghosttySurface = surface.surface {
ghostty_surface_set_unmodified_link_previews(ghosttySurface, true)
}
surface.setKeyboardCopyModeActive(keyboardCopyModeActive)
if !isAlreadyAttached {
updateSurfaceSize()
Expand Down Expand Up @@ -6765,6 +6768,21 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
lastKnownMousePointInView = point
}

/// Ghostty reports link hover asynchronously, so use the point captured
/// from the source mouse event instead of resampling the window afterward.
func terminalLinkPreviewAnchor(in previewView: NSView) -> NSPoint? {
guard let point = lastKnownMousePointInView,
let window,
previewView.window === window else { return nil }
return previewView.convert(point, from: self)
}

#if DEBUG
func setTerminalLinkPreviewPointerPointForTesting(_ point: NSPoint?) {
lastKnownMousePointInView = point
}
#endif

private func preferredPointerPoint(from eventPoint: NSPoint? = nil) -> NSPoint? {
if let eventPoint, pointIsUsableForWordResolution(eventPoint) {
lastKnownMousePointInView = eventPoint
Expand Down Expand Up @@ -8231,6 +8249,7 @@ final class GhosttySurfaceScrollView: NSView {
private let keyboardCopyModeBadgeIconView: NSImageView
private let keyboardCopyModeBadgeLabel: NSTextField
let linkHoverIndicatorView: TerminalLinkHoverIndicatorView
var terminalLinkPreviewController: TerminalLinkPreviewController?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the ownership of terminalLinkPreviewController.

The property is an internal mutable optional, so any file in the module can replace it or set it to nil. The scroll view is the sole owner of this controller and initializes it once at Line 8503.

linkHoverIndicatorView is assigned before super.init, so you can declare the controller as a non-optional stored let and assign it after super.init. That removes the optional chaining at both the setLinkHoverURL and deinit call sites and keeps a single owner for the preview lifecycle.

♻️ Proposed refactor
-    var terminalLinkPreviewController: TerminalLinkPreviewController?
+    let terminalLinkPreviewController: TerminalLinkPreviewController
         super.init(frame: .zero)
-        terminalLinkPreviewController = TerminalLinkPreviewController(view: linkHoverIndicatorView)
+        terminalLinkPreviewController = TerminalLinkPreviewController(view: linkHoverIndicatorView)

Then drop the ? at the update(...) and invalidate() call sites.

Also applies to: 8503-8503

🤖 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/GhosttyTerminalView.swift` at line 8237, Change
terminalLinkPreviewController from an internal mutable optional to a private
non-optional let owned by the scroll view, initialize it exactly once after
super.init at the existing initialization site, and remove optional chaining
from the setLinkHoverURL update and deinit invalidate call sites.

private let imageTransferIndicatorContainerView: NSView
private let imageTransferIndicatorView: NSVisualEffectView
private let imageTransferIndicatorSpinner: NSProgressIndicator
Expand Down Expand Up @@ -8496,6 +8515,7 @@ final class GhosttySurfaceScrollView: NSView {
documentView.addSubview(surfaceView)

super.init(frame: .zero)
terminalLinkPreviewController = TerminalLinkPreviewController(view: linkHoverIndicatorView)
wantsLayer = true
layer?.masksToBounds = true

Expand Down Expand Up @@ -8836,6 +8856,9 @@ final class GhosttySurfaceScrollView: NSView {
observers.forEach { NotificationCenter.default.removeObserver($0) }
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
dropZoneOverlayView.removeFromSuperview()
MainActor.assumeIsolated {
terminalLinkPreviewController?.invalidate()
}
cancelFocusRequest()
}

Expand Down
Loading