Merge remote-tracking branch 'origin/main' into pr-ssh-stack-main

# Conflicts:
#	CLI/cmux.swift
#	Sources/Panels/BrowserPanel.swift
#	Sources/TabManager.swift
#	Sources/Workspace.swift
#	cmuxTests/GhosttyConfigTests.swift
This commit is contained in:
Lawrence Chen 2026-03-16 23:16:12 -07:00
commit 03dc055138
No known key found for this signature in database
51 changed files with 13485 additions and 628 deletions

View file

@ -70,6 +70,12 @@ jobs:
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Install zig
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*

View file

@ -100,6 +100,12 @@ jobs:
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Install zig
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
- name: Clean DerivedData
run: |
# Remove stale build cache to avoid incremental build errors
@ -175,6 +181,12 @@ jobs:
fi
fi
- name: Run bundled Ghostty theme picker helper regression
run: |
set -euo pipefail
CMUX_SOURCE_PACKAGES_DIR="$PWD/.ci-source-packages" \
./tests/test_bundled_ghostty_theme_picker_helper.sh
- name: Run CLI version memory guard regression
run: |
set -euo pipefail
@ -224,6 +236,12 @@ jobs:
run: |
./scripts/download-prebuilt-ghosttykit.sh
- name: Install zig
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
- name: Clean DerivedData
run: rm -rf ~/Library/Developer/Xcode/DerivedData/GhosttyTabs-*

View file

@ -153,6 +153,9 @@ jobs:
- name: Install build deps
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- name: Download pre-built GhosttyKit.xcframework
@ -202,12 +205,16 @@ jobs:
set -euo pipefail
APP_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/MacOS/cmux"
CLI_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/cmux"
HELPER_BINARY="build-universal/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
APP_ARCHS="$(lipo -archs "$APP_BINARY")"
CLI_ARCHS="$(lipo -archs "$CLI_BINARY")"
HELPER_ARCHS="$(lipo -archs "$HELPER_BINARY")"
echo "App binary architectures: $APP_ARCHS"
echo "CLI binary architectures: $CLI_ARCHS"
echo "Ghostty helper architectures: $HELPER_ARCHS"
[[ "$APP_ARCHS" == *arm64* && "$APP_ARCHS" == *x86_64* ]]
[[ "$CLI_ARCHS" == *arm64* && "$CLI_ARCHS" == *x86_64* ]]
[[ "$HELPER_ARCHS" == *arm64* && "$HELPER_ARCHS" == *x86_64* ]]
- name: Run CLI version memory guard regression
if: needs.decide.outputs.should_publish != 'true' || steps.current_head_prebuild.outputs.still_current == 'true'
@ -350,9 +357,13 @@ jobs:
"build-universal/Build/Products/Release/cmux NIGHTLY.app"
do
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
done

View file

@ -101,6 +101,9 @@ jobs:
- name: Install build deps
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
npm install --global "create-dmg@${CREATE_DMG_VERSION}"
- name: Download pre-built GhosttyKit.xcframework
@ -165,6 +168,13 @@ jobs:
[ -x "$CLI_BINARY" ] || { echo "cmux CLI binary not found at $CLI_BINARY" >&2; exit 1; }
CMUX_CLI_BIN="$CLI_BINARY" python3 tests/test_cli_version_memory_guard.py
- name: Verify bundled Ghostty theme picker helper
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
set -euo pipefail
HELPER_BINARY="build/Build/Products/Release/cmux.app/Contents/Resources/bin/ghostty"
[ -x "$HELPER_BINARY" ] || { echo "Ghostty theme picker helper not found at $HELPER_BINARY" >&2; exit 1; }
- name: Inject Sparkle keys into Info.plist
if: steps.guard_release_assets.outputs.skip_all != 'true'
run: |
@ -215,9 +225,13 @@ jobs:
APP_PATH="build/Build/Products/Release/cmux.app"
ENTITLEMENTS="cmux.entitlements"
CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$APPLE_SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"

View file

@ -82,6 +82,12 @@ jobs:
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Install zig
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
- name: Create virtual display
run: |
set -euo pipefail

View file

@ -90,6 +90,12 @@ jobs:
rm GhosttyKit.xcframework.tar.gz
test -d GhosttyKit.xcframework
- name: Install zig
run: |
if ! command -v zig >/dev/null 2>&1; then
brew install zig
fi
- name: Create virtual display
run: |
set -euo pipefail

View file

@ -2,6 +2,28 @@
All notable changes to cmux are documented here.
## [0.62.2] - 2026-03-14
### Added
- Configurable sidebar tint color with separate light/dark mode support via Settings and config file (`sidebar-background`, `sidebar-tint-opacity`) ([#1465](https://github.com/manaflow-ai/cmux/pull/1465))
- Cmd+P all-surfaces search option ([#1382](https://github.com/manaflow-ai/cmux/pull/1382))
- `cmux themes` command with bundled Ghostty themes ([#1334](https://github.com/manaflow-ai/cmux/pull/1334), [#1314](https://github.com/manaflow-ai/cmux/pull/1314))
- Sidebar can now shrink to smaller widths ([#1420](https://github.com/manaflow-ai/cmux/pull/1420))
- Menu bar visibility setting ([#1330](https://github.com/manaflow-ai/cmux/pull/1330))
### Changed
- CLI Sentry events are now tagged with the app release ([#1408](https://github.com/manaflow-ai/cmux/pull/1408))
- Stable socket listener now falls back to a user-scoped path, and repeated startup failures are throttled ([#1351](https://github.com/manaflow-ai/cmux/pull/1351), [#1415](https://github.com/manaflow-ai/cmux/pull/1415))
### Fixed
- Command palette command-mode shortcut, navigation, and omnibar backspace or arrow-key regressions ([#1417](https://github.com/manaflow-ai/cmux/pull/1417), [#1413](https://github.com/manaflow-ai/cmux/pull/1413))
- Stale Claude sidebar status from missing hooks, OSC suppression, and PID cleanup ([#1306](https://github.com/manaflow-ai/cmux/pull/1306))
- Split cwd inheritance when the shell cwd is stale ([#1403](https://github.com/manaflow-ai/cmux/pull/1403))
- Crashes when creating a new workspace and when inserting a workspace into an orphaned window context ([#1391](https://github.com/manaflow-ai/cmux/pull/1391), [#1380](https://github.com/manaflow-ai/cmux/pull/1380))
- Cmd+W close behavior and close-confirmation shell-state regressions ([#1395](https://github.com/manaflow-ai/cmux/pull/1395), [#1386](https://github.com/manaflow-ai/cmux/pull/1386))
- macOS dictation NSTextInputClient conformance and terminal image-paste fallbacks ([#1410](https://github.com/manaflow-ai/cmux/pull/1410), [#1305](https://github.com/manaflow-ai/cmux/pull/1305), [#1361](https://github.com/manaflow-ai/cmux/pull/1361), [#1358](https://github.com/manaflow-ai/cmux/pull/1358))
- VS Code command palette target resolution, Ghostty Pure prompt redraws, and internal drag regressions ([#1389](https://github.com/manaflow-ai/cmux/pull/1389), [#1363](https://github.com/manaflow-ai/cmux/pull/1363), [#1316](https://github.com/manaflow-ai/cmux/pull/1316), [#1379](https://github.com/manaflow-ai/cmux/pull/1379))
## [0.62.1] - 2026-03-13
### Added

View file

@ -30,6 +30,102 @@ private final class CLISocketSentryTelemetry {
private static let startupLock = NSLock()
private static var started = false
private static let dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416"
private static func currentSentryReleaseName() -> String? {
guard let bundleIdentifier = currentSentryBundleIdentifier(),
let version = currentBundleVersionValue(forKey: "CFBundleShortVersionString"),
let build = currentBundleVersionValue(forKey: "CFBundleVersion")
else {
return nil
}
return "\(bundleIdentifier)@\(version)+\(build)"
}
private static func currentSentryBundleIdentifier() -> String? {
if let bundleIdentifier = ProcessInfo.processInfo.environment["CMUX_BUNDLE_ID"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
if let bundleIdentifier = currentSentryBundle()?.bundleIdentifier?
.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleIdentifier.isEmpty {
return bundleIdentifier
}
return nil
}
private static func currentBundleVersionValue(forKey key: String) -> String? {
guard let value = currentSentryBundle()?.infoDictionary?[key] as? String else {
return nil
}
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, !trimmed.contains("$(") else {
return nil
}
return trimmed
}
private static func currentSentryBundle() -> Bundle? {
if Bundle.main.bundleIdentifier?.isEmpty == false {
return Bundle.main
}
guard let executableURL = currentExecutableURL() else {
return Bundle.main
}
var current = executableURL.deletingLastPathComponent().standardizedFileURL
while true {
if current.pathExtension == "app", let bundle = Bundle(url: current) {
return bundle
}
if current.lastPathComponent == "Contents" {
let appURL = current.deletingLastPathComponent().standardizedFileURL
if appURL.pathExtension == "app", let bundle = Bundle(url: appURL) {
return bundle
}
}
guard let parent = parentSearchURL(for: current) else {
break
}
current = parent
}
return Bundle.main
}
private static func currentExecutableURL() -> URL? {
var size: UInt32 = 0
_ = _NSGetExecutablePath(nil, &size)
if size > 0 {
var buffer = Array<CChar>(repeating: 0, count: Int(size))
if _NSGetExecutablePath(&buffer, &size) == 0 {
return URL(fileURLWithPath: String(cString: buffer)).standardizedFileURL
}
}
return Bundle.main.executableURL?.standardizedFileURL
}
private static func parentSearchURL(for url: URL) -> URL? {
let standardized = url.standardizedFileURL
let path = standardized.path
guard !path.isEmpty, path != "/" else {
return nil
}
let parent = standardized.deletingLastPathComponent().standardizedFileURL
guard parent.path != path else {
return nil
}
return parent
}
#endif
init(command: String, commandArgs: [String], socketPath: String, processEnv: [String: String]) {
@ -180,6 +276,7 @@ private final class CLISocketSentryTelemetry {
guard !started else { return }
SentrySDK.start { options in
options.dsn = dsn
options.releaseName = currentSentryReleaseName()
#if DEBUG
options.environment = "development-cli"
#else
@ -226,6 +323,7 @@ private struct ClaudeHookSessionRecord: Codable {
var workspaceId: String
var surfaceId: String
var cwd: String?
var pid: Int?
var lastSubtitle: String?
var lastBody: String?
var startedAt: TimeInterval
@ -273,6 +371,7 @@ private final class ClaudeHookSessionStore {
workspaceId: String,
surfaceId: String,
cwd: String?,
pid: Int? = nil,
lastSubtitle: String? = nil,
lastBody: String? = nil
) throws {
@ -285,16 +384,22 @@ private final class ClaudeHookSessionStore {
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: nil,
pid: nil,
lastSubtitle: nil,
lastBody: nil,
startedAt: now,
updatedAt: now
)
record.workspaceId = workspaceId
record.surfaceId = surfaceId
if !surfaceId.isEmpty {
record.surfaceId = surfaceId
}
if let cwd = normalizeOptional(cwd) {
record.cwd = cwd
}
if let pid {
record.pid = pid
}
if let subtitle = normalizeOptional(lastSubtitle) {
record.lastSubtitle = subtitle
}
@ -9566,39 +9671,68 @@ struct CMUXCLI {
workspaceId: workspaceId,
client: client
)
let claudePid: Int? = {
guard let raw = ProcessInfo.processInfo.environment["CMUX_CLAUDE_PID"]?
.trimmingCharacters(in: .whitespacesAndNewlines),
let pid = Int(raw),
pid > 0 else {
return nil
}
return pid
}()
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd
cwd: parsedInput.cwd,
pid: claudePid
)
}
// Register PID for stale-session detection and OSC suppression,
// but don't set a visible status. "Running" only appears when the
// user submits a prompt (UserPromptSubmit) or Claude starts working
// (PreToolUse).
if let claudePid {
_ = try? sendV1Command(
"set_agent_pid claude_code \(claudePid) --tab=\(workspaceId)",
client: client
)
}
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Running",
icon: "bolt.fill",
color: "#4C8DFF"
)
print("OK")
case "stop", "idle":
telemetry.breadcrumb("claude-hook.stop")
let consumedSession = try? sessionStore.consume(
sessionId: parsedInput.sessionId,
workspaceId: fallbackWorkspaceId,
surfaceId: fallbackSurfaceId
)
let workspaceId = consumedSession?.workspaceId ?? fallbackWorkspaceId
try clearClaudeStatus(client: client, workspaceId: workspaceId)
// Turn ended. Don't consume session or clear PID Claude is still alive.
// Notification hook handles user-facing notifications; SessionEnd handles cleanup.
var workspaceId = fallbackWorkspaceId
var surfaceId = surfaceArg
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
surfaceId = mapped.surfaceId
}
if let completion = summarizeClaudeHookStop(
// Update session with transcript summary and send completion notification.
let completion = summarizeClaudeHookStop(
parsedInput: parsedInput,
sessionRecord: consumedSession
) {
let surfaceId = try resolveSurfaceIdForClaudeHook(
consumedSession?.surfaceId ?? surfaceArg,
sessionRecord: (try? sessionStore.lookup(sessionId: parsedInput.sessionId ?? ""))
)
if let sessionId = parsedInput.sessionId, let completion {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId ?? "",
cwd: parsedInput.cwd,
lastSubtitle: completion.subtitle,
lastBody: completion.body
)
}
if let completion {
let resolvedSurface = try resolveSurfaceIdForClaudeHook(
surfaceId,
workspaceId: workspaceId,
client: client
)
@ -9606,12 +9740,18 @@ struct CMUXCLI {
let subtitle = sanitizeNotificationField(completion.subtitle)
let body = sanitizeNotificationField(completion.body)
let payload = "\(title)|\(subtitle)|\(body)"
let response = try client.send(command: "notify_target \(workspaceId) \(surfaceId) \(payload)")
print(response)
} else {
print("OK")
_ = try? sendV1Command("notify_target \(workspaceId) \(resolvedSurface) \(payload)", client: client)
}
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: "Idle",
icon: "pause.circle.fill",
color: "#8E8E93"
)
print("OK")
case "prompt-submit":
telemetry.breadcrumb("claude-hook.prompt-submit")
var workspaceId = fallbackWorkspaceId
@ -9632,7 +9772,7 @@ struct CMUXCLI {
case "notification", "notify":
telemetry.breadcrumb("claude-hook.notification")
let summary = summarizeClaudeHookNotification(rawInput: rawInput)
var summary = summarizeClaudeHookNotification(rawInput: rawInput)
var workspaceId = fallbackWorkspaceId
var preferredSurface = surfaceArg
@ -9641,6 +9781,12 @@ struct CMUXCLI {
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
preferredSurface = mapped.surfaceId
// If PreToolUse saved a richer message (e.g. from AskUserQuestion),
// use it instead of the generic notification text.
if let savedBody = mapped.lastBody, !savedBody.isEmpty,
summary.body.contains("needs your attention") || summary.body.contains("needs your input") {
summary = (subtitle: mapped.lastSubtitle ?? summary.subtitle, body: savedBody)
}
}
let surfaceId = try resolveSurfaceIdForClaudeHook(
@ -9675,11 +9821,86 @@ struct CMUXCLI {
)
print(response)
case "session-end":
telemetry.breadcrumb("claude-hook.session-end")
// Final cleanup when Claude process exits.
// Only clear when we are the primary cleanup path (Stop didn't fire first).
// If Stop already consumed the session, consumedSession is nil and we skip
// to avoid wiping the completion notification that Stop just delivered.
let consumedSession = try? sessionStore.consume(
sessionId: parsedInput.sessionId,
workspaceId: fallbackWorkspaceId,
surfaceId: fallbackSurfaceId
)
if let consumedSession {
let workspaceId = consumedSession.workspaceId
_ = try? clearClaudeStatus(client: client, workspaceId: workspaceId)
_ = try? sendV1Command("clear_agent_pid claude_code --tab=\(workspaceId)", client: client)
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
}
print("OK")
case "pre-tool-use":
telemetry.breadcrumb("claude-hook.pre-tool-use")
// Clears "Needs input" status and notification when Claude resumes work
// (e.g. after permission grant). Runs async so it doesn't block tool execution.
var workspaceId = fallbackWorkspaceId
var claudePid: Int? = nil
if let sessionId = parsedInput.sessionId,
let mapped = try? sessionStore.lookup(sessionId: sessionId),
let mappedWorkspace = try? resolveWorkspaceIdForClaudeHook(mapped.workspaceId, client: client) {
workspaceId = mappedWorkspace
claudePid = mapped.pid
}
// AskUserQuestion means Claude is about to ask the user something.
// Save question text in session so the Notification handler can use it
// instead of the generic "Claude Code needs your attention".
if let toolName = parsedInput.object?["tool_name"] as? String,
toolName == "AskUserQuestion",
let question = describeAskUserQuestion(parsedInput.object),
let sessionId = parsedInput.sessionId {
// Preserve the existing surfaceId from SessionStart; passing ""
// would overwrite it and cause notifications to target the wrong workspace.
let existingSurfaceId = (try? sessionStore.lookup(sessionId: sessionId))?.surfaceId ?? ""
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: existingSurfaceId,
cwd: parsedInput.cwd,
lastSubtitle: "Waiting",
lastBody: question
)
// Don't clear notifications or set status here.
// The Notification hook fires right after and will use the saved question.
print("OK")
return
}
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
let statusValue: String
if UserDefaults.standard.bool(forKey: "claudeCodeVerboseStatus"),
let toolStatus = describeToolUse(parsedInput.object) {
statusValue = toolStatus
} else {
statusValue = "Running"
}
try setClaudeStatus(
client: client,
workspaceId: workspaceId,
value: statusValue,
icon: "bolt.fill",
color: "#4C8DFF",
pid: claudePid
)
print("OK")
case "help", "--help", "-h":
telemetry.breadcrumb("claude-hook.help")
print(
"""
cmux claude-hook <session-start|stop|notification> [--workspace <id|index>] [--surface <id|index>]
cmux claude-hook <session-start|stop|session-end|notification|prompt-submit|pre-tool-use> [--workspace <id|index>] [--surface <id|index>]
"""
)
@ -9693,17 +9914,105 @@ struct CMUXCLI {
workspaceId: String,
value: String,
icon: String,
color: String
color: String,
pid: Int? = nil
) throws {
_ = try client.send(
command: "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
)
var cmd = "set_status claude_code \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
if let pid {
cmd += " --pid=\(pid)"
}
_ = try client.send(command: cmd)
}
private func clearClaudeStatus(client: SocketClient, workspaceId: String) throws {
_ = try client.send(command: "clear_status claude_code --tab=\(workspaceId)")
}
private func describeAskUserQuestion(_ object: [String: Any]?) -> String? {
guard let object,
let input = object["tool_input"] as? [String: Any],
let questions = input["questions"] as? [[String: Any]],
let first = questions.first else { return nil }
var parts: [String] = []
if let question = first["question"] as? String, !question.isEmpty {
parts.append(question)
} else if let header = first["header"] as? String, !header.isEmpty {
parts.append(header)
}
if let options = first["options"] as? [[String: Any]] {
let labels = options.compactMap { $0["label"] as? String }
if !labels.isEmpty {
parts.append(labels.map { "[\($0)]" }.joined(separator: " "))
}
}
if parts.isEmpty { return "Asking a question" }
return parts.joined(separator: "\n")
}
private func describeToolUse(_ object: [String: Any]?) -> String? {
guard let object, let toolName = object["tool_name"] as? String else { return nil }
let input = object["tool_input"] as? [String: Any]
switch toolName {
case "Read":
if let path = input?["file_path"] as? String {
return "Reading \(shortenPath(path))"
}
return "Reading file"
case "Edit":
if let path = input?["file_path"] as? String {
return "Editing \(shortenPath(path))"
}
return "Editing file"
case "Write":
if let path = input?["file_path"] as? String {
return "Writing \(shortenPath(path))"
}
return "Writing file"
case "Bash":
if let cmd = input?["command"] as? String {
let first = cmd.components(separatedBy: .whitespacesAndNewlines).first ?? cmd
let short = String(first.prefix(30))
return "Running \(short)"
}
return "Running command"
case "Glob":
if let pattern = input?["pattern"] as? String {
return "Searching \(String(pattern.prefix(30)))"
}
return "Searching files"
case "Grep":
if let pattern = input?["pattern"] as? String {
return "Grep \(String(pattern.prefix(30)))"
}
return "Searching code"
case "Agent":
if let desc = input?["description"] as? String {
return String(desc.prefix(40))
}
return "Subagent"
case "WebFetch":
return "Fetching URL"
case "WebSearch":
if let query = input?["query"] as? String {
return "Search: \(String(query.prefix(30)))"
}
return "Web search"
default:
return toolName
}
}
private func shortenPath(_ path: String) -> String {
let url = URL(fileURLWithPath: path)
let name = url.lastPathComponent
return name.isEmpty ? String(path.suffix(30)) : name
}
private func resolveWorkspaceIdForClaudeHook(_ raw: String?, client: SocketClient) throws -> String {
if let raw, !raw.isEmpty, let candidate = try? resolveWorkspaceId(raw, client: client) {
let probe = try? client.sendV2(method: "surface.list", params: ["workspace_id": candidate])
@ -9904,20 +10213,13 @@ struct CMUXCLI {
let signal = signalParts.compactMap { $0 }.joined(separator: " ")
var classified = classifyClaudeNotification(signal: signal, message: normalizedMessage)
if let session, !session.isEmpty {
let shortSession = String(session.prefix(8))
if !classified.body.contains(shortSession) {
classified.body = "\(classified.body) [\(shortSession)]"
}
}
classified.body = truncate(classified.body, maxLength: 180)
return classified
}
private func classifyClaudeNotification(signal: String, message: String) -> (subtitle: String, body: String) {
let lower = "\(signal) \(message)".lowercased()
if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") {
if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") {
let body = message.isEmpty ? "Approval needed" : message
return ("Permission", body)
}
@ -9925,12 +10227,19 @@ struct CMUXCLI {
let body = message.isEmpty ? "Claude reported an error" : message
return ("Error", body)
}
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("prompt") {
let body = message.isEmpty ? "Claude is waiting for your input" : message
if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") {
let body = message.isEmpty ? "Task completed" : message
return ("Completed", body)
}
if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") {
let body = message.isEmpty ? "Waiting for input" : message
return ("Waiting", body)
}
let body = message.isEmpty ? "Claude needs your input" : message
return ("Attention", body)
// Use the message directly if it's meaningful (not a generic placeholder).
if !message.isEmpty, message != "Claude needs your input" {
return ("Attention", message)
}
return ("Attention", "Claude needs your attention")
}
private func firstString(in object: [String: Any], keys: [String]) -> String? {
@ -9958,9 +10267,8 @@ struct CMUXCLI {
}
private func sanitizeNotificationField(_ value: String) -> String {
let normalized = normalizedSingleLine(value)
return normalizedSingleLine(value)
.replacingOccurrences(of: "|", with: "¦")
return truncate(normalized, maxLength: 180)
}
private func versionSummary() -> String {

View file

@ -29,6 +29,7 @@
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; };
A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; };
A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; };
A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; };
A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; };
A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; };
@ -83,12 +84,14 @@
B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */; };
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; };
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */; };
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; };
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; };
F1000000A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */; };
F2000000A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */; };
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; };
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */; };
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */; };
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; };
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */; };
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */; };
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */; };
@ -180,6 +183,7 @@
A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; };
A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; };
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; };
A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = "<group>"; };
A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = "<group>"; };
A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = "<group>"; };
@ -229,12 +233,14 @@
B9000026A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseWindowConfirmDialogUITests.swift; sourceTree = "<group>"; };
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = "<group>"; };
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarSuggestionsUITests.swift; sourceTree = "<group>"; };
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = "<group>"; };
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = "<group>"; };
F1000001A1B2C3D4E5F60718 /* CmuxWebViewKeyEquivalentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewKeyEquivalentTests.swift; sourceTree = "<group>"; };
F2000001A1B2C3D4E5F60718 /* UpdatePillReleaseVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePillReleaseVisibilityTests.swift; sourceTree = "<group>"; };
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = "<group>"; };
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfigTests.swift; sourceTree = "<group>"; };
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPersistenceTests.swift; sourceTree = "<group>"; };
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = "<group>"; };
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateShortcutRoutingTests.swift; sourceTree = "<group>"; };
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentViewVisibilityTests.swift; sourceTree = "<group>"; };
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocketControlPasswordStoreTests.swift; sourceTree = "<group>"; };
@ -330,7 +336,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n";
shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :CMUXCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :CMUXCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
@ -387,6 +393,7 @@
A5001412 /* BrowserPanel.swift */,
A5001413 /* TerminalPanelView.swift */,
A5001414 /* BrowserPanelView.swift */,
A5007421 /* BrowserPopupWindowController.swift */,
A5001418 /* MarkdownPanel.swift */,
A5001419 /* MarkdownPanelView.swift */,
A5001510 /* CmuxWebView.swift */,
@ -456,6 +463,7 @@
B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */,
D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */,
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */,
);
@ -470,6 +478,7 @@
F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */,
F4000001A1B2C3D4E5F60718 /* GhosttyConfigTests.swift */,
F5000001A1B2C3D4E5F60718 /* SessionPersistenceTests.swift */,
FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */,
F6000001A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift */,
F7000001A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift */,
F8000001A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift */,
@ -659,6 +668,7 @@
A5001402 /* BrowserPanel.swift in Sources */,
A5001403 /* TerminalPanelView.swift in Sources */,
A5001404 /* BrowserPanelView.swift in Sources */,
A5007420 /* BrowserPopupWindowController.swift in Sources */,
A5001420 /* MarkdownPanel.swift in Sources */,
A5001421 /* MarkdownPanelView.swift in Sources */,
A5001500 /* CmuxWebView.swift in Sources */,
@ -696,6 +706,7 @@
B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
C0B4D9B0A1B2C3D4E5F60718 /* UpdatePillUITests.swift in Sources */,
E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */,
);
@ -710,6 +721,7 @@
F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */,
F4000000A1B2C3D4E5F60718 /* GhosttyConfigTests.swift in Sources */,
F5000000A1B2C3D4E5F60718 /* SessionPersistenceTests.swift in Sources */,
FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */,
F6000000A1B2C3D4E5F60718 /* AppDelegateShortcutRoutingTests.swift in Sources */,
F7000000A1B2C3D4E5F60718 /* WorkspaceContentViewVisibilityTests.swift in Sources */,
F8000000A1B2C3D4E5F60718 /* SocketControlPasswordStoreTests.swift in Sources */,
@ -818,7 +830,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -827,7 +839,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.62.1;
MARKETING_VERSION = 0.62.2;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -857,7 +869,7 @@
CODE_SIGN_ENTITLEMENTS = "";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
DEVELOPMENT_TEAM = "";
ENABLE_HARDENED_RUNTIME = NO;
GENERATE_INFOPLIST_FILE = NO;
@ -866,7 +878,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.62.1;
MARKETING_VERSION = 0.62.2;
OTHER_LDFLAGS = (
"-lc++",
"-framework",
@ -933,10 +945,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.1;
MARKETING_VERSION = 0.62.2;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -950,10 +962,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.1;
MARKETING_VERSION = 0.62.2;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.appuitests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -967,10 +979,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.1;
MARKETING_VERSION = 0.62.2;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -986,10 +998,10 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 76;
CURRENT_PROJECT_VERSION = 77;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.62.1;
MARKETING_VERSION = 0.62.2;
ONLY_ACTIVE_ARCH = NO;
PRODUCT_BUNDLE_IDENTIFIER = com.cmuxterm.apptests;
PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -12,6 +12,21 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Folder</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.folder</string>
</array>
</dict>
</array>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>

File diff suppressed because it is too large Load diff

View file

@ -76,9 +76,17 @@ for arg in "$@"; do
esac
done
# Export the wrapper's PID. Because we exec claude below, this PID becomes
# the actual claude process PID, which hooks use for stale-session detection.
export CMUX_CLAUDE_PID=$$
# Build hooks settings JSON.
# Claude Code merges --settings additively with the user's own settings.json.
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}]}}'
# - SessionStart/Stop/Notification: existing lifecycle hooks
# - SessionEnd: cleanup when Claude exits (covers Ctrl+C where Stop doesn't fire)
# - UserPromptSubmit: clears "Needs input" and sets "Running" on new prompt
# - PreToolUse: clears "Needs input" when Claude resumes after permission grant (async to avoid latency)
HOOKS_JSON='{"hooks":{"SessionStart":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-start","timeout":10}]}],"Stop":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook stop","timeout":10}]}],"SessionEnd":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook session-end","timeout":1}]}],"Notification":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook notification","timeout":10}]}],"UserPromptSubmit":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook prompt-submit","timeout":10}]}],"PreToolUse":[{"matcher":"","hooks":[{"type":"command","command":"cmux claude-hook pre-tool-use","timeout":5,"async":true}]}]}}'
if [[ "$SKIP_SESSION_ID" == true ]]; then
exec "$REAL_CLAUDE" --settings "$HOOKS_JSON" "$@"

View file

@ -145,6 +145,40 @@ _cmux_pr_output_indicates_no_pull_request() {
|| "$output" == *"no pull request associated"* ]]
}
_cmux_github_repo_slug_for_path() {
local repo_path="$1"
local remote_url="" path_part=""
[[ -n "$repo_path" ]] || return 0
remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)"
[[ -n "$remote_url" ]] || return 0
case "$remote_url" in
git@github.com:*)
path_part="${remote_url#git@github.com:}"
;;
ssh://git@github.com/*)
path_part="${remote_url#ssh://git@github.com/}"
;;
https://github.com/*)
path_part="${remote_url#https://github.com/}"
;;
http://github.com/*)
path_part="${remote_url#http://github.com/}"
;;
git://github.com/*)
path_part="${remote_url#git://github.com/}"
;;
*)
return 0
;;
esac
path_part="${path_part%.git}"
[[ "$path_part" == */* ]] || return 0
printf '%s\n' "$path_part"
}
_cmux_report_pr_for_path() {
local repo_path="$1"
[[ -n "$repo_path" ]] || {
@ -159,18 +193,26 @@ _cmux_report_pr_for_path() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local branch gh_output gh_error="" err_file="" gh_status number state url status_opt=""
local branch repo_slug="" gh_output="" gh_error="" err_file="" gh_status number state url status_opt=""
local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
local -a gh_repo_args=()
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_clear_pr_for_panel
return 0
fi
repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")"
if [[ -n "$repo_slug" ]]; then
gh_repo_args=(--repo "$repo_slug")
fi
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
@ -180,18 +222,54 @@ _cmux_report_pr_for_path() {
gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( gh_status != 0 )); then
if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
_cmux_clear_pr_for_panel
return 0
if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
:
else
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
implicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
implicit_probe_indicates_no_pr=1
fi
# `gh pr view` without an explicit branch can fail to resolve the
# current worktree branch even when the branch has a PR. Fall back to
# the explicit branch name before concluding there is no PR.
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
explicit_branch_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view "$branch" \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
)"
explicit_branch_status=$?
if [[ -f "$err_file" ]]; then
explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
gh_output="$explicit_branch_output"
gh_status=0
else
if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
explicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
explicit_probe_indicates_no_pr=1
fi
if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
_cmux_clear_pr_for_panel
return 0
fi
# Preserve the last-known PR badge when gh fails transiently, then retry
# on the next background poll instead of clearing visible state.
return 1
fi
# Preserve the last-known PR badge when gh fails transiently, then retry
# on the next background poll instead of clearing visible state.
return 1
fi
if [[ -z "$gh_output" ]]; then
_cmux_clear_pr_for_panel
return 0
fi
IFS=$'\t' read -r number state url <<< "$gh_output"
@ -263,11 +341,9 @@ _cmux_run_pr_probe_with_timeout() {
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM
sleep 0.1
if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
fi
# Use SIGKILL directly to avoid blocking sleep in preexec.
# The poll loop is lightweight and safe to kill abruptly.
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
_CMUX_PR_POLL_PID=""
fi
}
@ -378,11 +454,18 @@ _cmux_prompt_command() {
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Also invalidate the PR poller so it refreshes with the new branch.
_CMUX_PR_FORCE=1
if [[ -n "$head_signature" ]]; then
if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
# The first observed HEAD value is just the session baseline.
# Treating it as a branch change clears restore-seeded PR badges
# before the first background probe can confirm the current PR.
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Also invalidate the PR poller so it refreshes with the new branch.
_CMUX_PR_FORCE=1
fi
fi
fi

View file

@ -263,6 +263,40 @@ _cmux_pr_output_indicates_no_pull_request() {
|| "$output" == *"no pull request associated"* ]]
}
_cmux_github_repo_slug_for_path() {
local repo_path="$1"
local remote_url="" path_part=""
[[ -n "$repo_path" ]] || return 0
remote_url="$(git -C "$repo_path" remote get-url origin 2>/dev/null)"
[[ -n "$remote_url" ]] || return 0
case "$remote_url" in
git@github.com:*)
path_part="${remote_url#git@github.com:}"
;;
ssh://git@github.com/*)
path_part="${remote_url#ssh://git@github.com/}"
;;
https://github.com/*)
path_part="${remote_url#https://github.com/}"
;;
http://github.com/*)
path_part="${remote_url#http://github.com/}"
;;
git://github.com/*)
path_part="${remote_url#git://github.com/}"
;;
*)
return 0
;;
esac
path_part="${path_part%.git}"
[[ "$path_part" == */* ]] || return 0
print -r -- "$path_part"
}
_cmux_report_pr_for_path() {
local repo_path="$1"
[[ -n "$repo_path" ]] || {
@ -277,18 +311,27 @@ _cmux_report_pr_for_path() {
[[ -n "$CMUX_TAB_ID" ]] || return 0
[[ -n "$CMUX_PANEL_ID" ]] || return 0
local branch gh_output gh_error="" err_file="" number state url status_opt="" gh_status
local branch repo_slug="" gh_output="" gh_error="" err_file="" number state url status_opt="" gh_status
local explicit_branch_output="" explicit_branch_error="" explicit_branch_status=0
local implicit_probe_indicates_no_pr=0 explicit_probe_indicates_no_pr=0
local -a gh_repo_args
gh_repo_args=()
branch="$(git -C "$repo_path" branch --show-current 2>/dev/null)"
if [[ -z "$branch" ]] || ! command -v gh >/dev/null 2>&1; then
_cmux_clear_pr_for_panel
return 0
fi
repo_slug="$(_cmux_github_repo_slug_for_path "$repo_path")"
if [[ -n "$repo_slug" ]]; then
gh_repo_args=(--repo "$repo_slug")
fi
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
gh_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
@ -298,18 +341,54 @@ _cmux_report_pr_for_path() {
gh_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( gh_status != 0 )); then
if _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
_cmux_clear_pr_for_panel
return 0
if (( gh_status == 0 )) && [[ -n "$gh_output" ]]; then
:
else
if (( gh_status == 0 )) && [[ -z "$gh_output" ]]; then
implicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$gh_error"; then
implicit_probe_indicates_no_pr=1
fi
# `gh pr view` without an explicit branch can fail to resolve the
# current worktree branch even when the branch has a PR. Fall back to
# the explicit branch name before concluding there is no PR.
err_file="$(/usr/bin/mktemp "${TMPDIR:-/tmp}/cmux-gh-pr-view.XXXXXX" 2>/dev/null || true)"
[[ -n "$err_file" ]] || return 1
explicit_branch_output="$(
builtin cd "$repo_path" 2>/dev/null \
&& gh pr view "$branch" \
"${gh_repo_args[@]}" \
--json number,state,url \
--jq '[.number, .state, .url] | @tsv' \
2>"$err_file"
)"
explicit_branch_status=$?
if [[ -f "$err_file" ]]; then
explicit_branch_error="$("/bin/cat" -- "$err_file" 2>/dev/null || true)"
/bin/rm -f -- "$err_file" >/dev/null 2>&1 || true
fi
if (( explicit_branch_status == 0 )) && [[ -n "$explicit_branch_output" ]]; then
gh_output="$explicit_branch_output"
gh_status=0
else
if (( explicit_branch_status == 0 )) && [[ -z "$explicit_branch_output" ]]; then
explicit_probe_indicates_no_pr=1
elif _cmux_pr_output_indicates_no_pull_request "$explicit_branch_error"; then
explicit_probe_indicates_no_pr=1
fi
if (( implicit_probe_indicates_no_pr )) && (( explicit_probe_indicates_no_pr )); then
_cmux_clear_pr_for_panel
return 0
fi
# Keep the last-known PR badge on transient gh failures (auth hiccups,
# API lag after creation, or rate limiting) and retry on the next poll.
return 1
fi
# Keep the last-known PR badge on transient gh failures (auth hiccups,
# API lag after creation, or rate limiting) and retry on the next poll.
return 1
fi
if [[ -z "$gh_output" ]]; then
_cmux_clear_pr_for_panel
return 0
fi
local IFS=$'\t'
@ -382,11 +461,9 @@ _cmux_run_pr_probe_with_timeout() {
_cmux_stop_pr_poll_loop() {
if [[ -n "$_CMUX_PR_POLL_PID" ]]; then
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" TERM
sleep 0.1
if kill -0 "$_CMUX_PR_POLL_PID" >/dev/null 2>&1; then
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
fi
# Use SIGKILL directly to avoid blocking sleep in preexec.
# The poll loop is lightweight and safe to kill abruptly.
_cmux_kill_process_tree "$_CMUX_PR_POLL_PID" KILL
_CMUX_PR_POLL_PID=""
fi
}
@ -554,14 +631,21 @@ _cmux_precmd() {
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
_CMUX_PR_FORCE=1
should_git=1
if [[ -n "$head_signature" ]]; then
if [[ -z "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
# The first observed HEAD value establishes the baseline for this
# shell session. Don't treat it as a branch change or we'll clear
# restore-seeded PR badges before the first background probe runs.
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
elif [[ "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1
_CMUX_PR_FORCE=1
should_git=1
fi
fi
fi

View file

@ -368,13 +368,24 @@ enum FinderServicePathResolver {
return canonical
}
private static func resolvedDirectoryURL(from url: URL) -> URL {
let standardized = url.standardizedFileURL
if standardized.hasDirectoryPath {
return standardized
}
if let resourceValues = try? standardized.resourceValues(forKeys: [.isDirectoryKey]),
resourceValues.isDirectory == true {
return standardized
}
return standardized.deletingLastPathComponent()
}
static func orderedUniqueDirectories(from pathURLs: [URL]) -> [String] {
var seen: Set<String> = []
var directories: [String] = []
for url in pathURLs {
let standardized = url.standardizedFileURL
let directoryURL = standardized.hasDirectoryPath ? standardized : standardized.deletingLastPathComponent()
let directoryURL = resolvedDirectoryURL(from: url)
let path = canonicalDirectoryPath(directoryURL.path(percentEncoded: false))
guard !path.isEmpty else { continue }
if seen.insert(path).inserted {
@ -1696,6 +1707,32 @@ func shouldRouteTerminalFontZoomShortcutToGhostty(
) != nil
}
@discardableResult
func startOrFocusTerminalSearch(
_ terminalSurface: TerminalSurface,
searchFocusNotifier: @escaping (TerminalSurface) -> Void = {
NotificationCenter.default.post(name: .ghosttySearchFocus, object: $0)
}
) -> Bool {
if terminalSurface.searchState != nil {
searchFocusNotifier(terminalSurface)
return true
}
if terminalSurface.performBindingAction("start_search") {
DispatchQueue.main.async { [weak terminalSurface] in
guard let terminalSurface, terminalSurface.searchState == nil else { return }
terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
}
return true
}
terminalSurface.searchState = TerminalSurface.SearchState()
searchFocusNotifier(terminalSurface)
return true
}
/// Let AppKit own native Cmd+` window cycling so key-window changes do not
/// re-enter our direct-to-menu shortcut path.
func shouldRouteCommandEquivalentDirectlyToMainMenu(_ event: NSEvent) -> Bool {
@ -2128,6 +2165,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
Self.shared = self
}
func application(_ application: NSApplication, open urls: [URL]) {
let directories = externalOpenDirectories(from: urls)
guard !directories.isEmpty else { return }
prepareForExplicitOpenIntentAtStartup()
for directory in directories {
openWorkspaceForExternalDirectory(
workingDirectory: directory,
debugSource: "application.openURLs"
)
}
}
func applicationDidFinishLaunching(_ notification: Notification) {
let env = ProcessInfo.processInfo.environment
let isRunningUnderXCTest = isRunningUnderXCTest(env)
@ -2221,8 +2271,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
configureUserNotifications()
installMenuBarVisibilityObserver()
syncMenuBarExtraVisibility()
// Sparkle updater is started lazily on first manual check. This avoids any
// first-launch permission prompts and keeps cmux aligned with the update pill UI.
updateController.startUpdaterIfNeeded()
}
titlebarAccessoryController.start()
windowDecorationsController.start()
@ -2337,7 +2386,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
stopSocketListenerHealthMonitor()
TerminalController.shared.stop()
VSCodeServeWebController.shared.stop()
BrowserHistoryStore.shared.flushPendingSaves()
BrowserProfileStore.shared.flushPendingSaves()
if TelemetrySettings.enabledForCurrentLaunch {
PostHogAnalytics.shared.flush()
}
@ -5052,11 +5101,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
target: ServiceOpenTarget,
error: AutoreleasingUnsafeMutablePointer<NSString>
) {
didHandleExplicitOpenIntentAtStartup = true
if !didAttemptStartupSessionRestore {
startupSessionSnapshot = nil
didAttemptStartupSessionRestore = true
}
prepareForExplicitOpenIntentAtStartup()
let pathURLs = servicePathURLs(from: pasteboard)
guard !pathURLs.isEmpty else {
@ -5064,7 +5109,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return
}
let directories = FinderServicePathResolver.orderedUniqueDirectories(from: pathURLs)
let directories = externalOpenDirectories(from: pathURLs)
guard !directories.isEmpty else {
error.pointee = Self.serviceErrorNoPath
return
@ -5109,10 +5154,32 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func openWorkspaceFromService(workingDirectory: String) {
openWorkspaceForExternalDirectory(
workingDirectory: workingDirectory,
debugSource: "service.openTab"
)
}
private func prepareForExplicitOpenIntentAtStartup() {
didHandleExplicitOpenIntentAtStartup = true
if !didAttemptStartupSessionRestore {
startupSessionSnapshot = nil
didAttemptStartupSessionRestore = true
}
}
private func externalOpenDirectories(from urls: [URL]) -> [String] {
FinderServicePathResolver.orderedUniqueDirectories(from: urls.filter { $0.isFileURL })
}
private func openWorkspaceForExternalDirectory(
workingDirectory: String,
debugSource: String
) {
if addWorkspaceInPreferredMainWindow(
workingDirectory: workingDirectory,
shouldBringToFront: true,
debugSource: "service.openTab"
debugSource: debugSource
) != nil {
return
}
@ -7902,11 +7969,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let commandPaletteResponderActiveInTargetWindow = commandPaletteTargetWindow.map {
isCommandPaletteResponderActive(in: $0)
} ?? false
let commandPaletteEffectiveInTargetWindow =
let commandPaletteInteractiveInTargetWindow =
commandPaletteVisibleInTargetWindow
|| commandPalettePendingOpenInTargetWindow
|| commandPaletteOverlayVisibleInTargetWindow
|| commandPaletteResponderActiveInTargetWindow
let commandPaletteEffectiveInTargetWindow =
commandPaletteInteractiveInTargetWindow
|| commandPalettePendingOpenInTargetWindow
if normalizedFlags.isEmpty, event.keyCode == 53 {
let activePaletteWindow = activeCommandPaletteWindow()
@ -7995,7 +8064,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
chars: chars,
keyCode: event.keyCode
),
commandPaletteVisibleInTargetWindow,
commandPaletteInteractiveInTargetWindow,
let paletteWindow = commandPaletteShortcutWindow {
NotificationCenter.default.post(
name: .commandPaletteMoveSelection,
@ -8005,7 +8074,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if commandPaletteVisibleInTargetWindow,
if commandPaletteInteractiveInTargetWindow,
let paletteWindow = commandPaletteShortcutWindow {
let paletteFieldEditorHasMarkedText = commandPaletteFieldEditorHasMarkedText(in: paletteWindow)
if normalizedFlags.isEmpty, event.keyCode == 53 {
@ -8050,6 +8119,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// Scope the omnibar check to the shortcut's routed window context so a
// focused omnibar in another window does not suppress Cmd+P here.
let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil
let isCommandShiftP = matchShortcut(
event: event,
shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false)
)
if isCommandShiftP {
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP")
return true
}
let isCommandP = !hasFocusedAddressBarInShortcutContext
&& matchShortcut(
event: event,
@ -8061,16 +8140,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
let isCommandShiftP = matchShortcut(
event: event,
shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false)
)
if isCommandShiftP {
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP")
return true
}
if shouldConsumeShortcutWhileCommandPaletteVisible(
isCommandPaletteVisible: commandPaletteEffectiveInTargetWindow,
normalizedFlags: normalizedFlags,
@ -8348,8 +8417,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
event: event,
shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false)
) {
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
targetWindow.identifier?.rawValue == "cmux.settings" {
// Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel.
// This AppDelegate path is a fallback for cases where AppKit routes the
// event through the global shortcut handler first.
if let targetWindow = [NSApp.keyWindow, event.window]
.compactMap({ $0 })
.first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) {
#if DEBUG
dlog("shortcut.cmdW route=browserPopup")
#endif
targetWindow.performClose(nil)
return true
} else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
cmuxWindowShouldOwnCloseShortcut(targetWindow) {
targetWindow.performClose(nil)
} else {
let responder = event.window?.firstResponder
@ -8781,7 +8861,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@discardableResult
func openBrowserAndFocusAddressBar(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
guard let panelId = tabManager?.openBrowser(url: url, insertAtEnd: insertAtEnd) else {
let preferredProfileID =
tabManager?.focusedBrowserPanel?.profileID
?? tabManager?.selectedWorkspace?.preferredBrowserProfileID
guard let panelId = tabManager?.openBrowser(
url: url,
preferredProfileID: preferredProfileID,
insertAtEnd: insertAtEnd
) else {
#if DEBUG
dlog(
"browser.focus.openAndFocus result=open_failed insertAtEnd=\(insertAtEnd ? 1 : 0) " +
@ -9528,6 +9615,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
case ".": return 47 // kVK_ANSI_Period
case "`": return 50 // kVK_ANSI_Grave
case "\r": return 36 // kVK_Return
case "": return 123 // kVK_LeftArrow
case "": return 124 // kVK_RightArrow
case "": return 125 // kVK_DownArrow
case "": return 126 // kVK_UpArrow
default:
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,13 @@ struct GhosttyConfig {
var selectionBackground: NSColor = NSColor(hex: "#57584f")!
var selectionForeground: NSColor = NSColor(hex: "#fdfff1")!
// Sidebar appearance
var rawSidebarBackground: String?
var sidebarBackground: NSColor?
var sidebarBackgroundLight: NSColor?
var sidebarBackgroundDark: NSColor?
var sidebarTintOpacity: Double?
// Palette colors (0-15)
var palette: [Int: NSColor] = [:]
@ -134,6 +141,54 @@ struct GhosttyConfig {
return []
}
mutating func resolveSidebarBackground(preferredColorScheme: ColorSchemePreference) {
guard let raw = rawSidebarBackground else { return }
let lightResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .light)
let darkResolved = Self.resolveThemeName(from: raw, preferredColorScheme: .dark)
let hasDualMode = lightResolved != darkResolved
if hasDualMode {
sidebarBackgroundLight = NSColor(hex: lightResolved)
sidebarBackgroundDark = NSColor(hex: darkResolved)
}
let resolved = Self.resolveThemeName(from: raw, preferredColorScheme: preferredColorScheme)
if let color = NSColor(hex: resolved) {
sidebarBackground = color
}
}
func applySidebarAppearanceToUserDefaults() {
guard rawSidebarBackground != nil else {
if let opacity = sidebarTintOpacity {
UserDefaults.standard.set(opacity, forKey: "sidebarTintOpacity")
}
return
}
let defaults = UserDefaults.standard
if let light = sidebarBackgroundLight {
defaults.set(light.hexString(), forKey: "sidebarTintHexLight")
} else {
defaults.removeObject(forKey: "sidebarTintHexLight")
}
if let dark = sidebarBackgroundDark {
defaults.set(dark.hexString(), forKey: "sidebarTintHexDark")
} else {
defaults.removeObject(forKey: "sidebarTintHexDark")
}
if let color = sidebarBackground {
defaults.set(color.hexString(), forKey: "sidebarTintHex")
} else {
defaults.removeObject(forKey: "sidebarTintHex")
}
if let opacity = sidebarTintOpacity {
defaults.set(opacity, forKey: "sidebarTintOpacity")
}
}
private static func loadFromDisk(preferredColorScheme: ColorSchemePreference) -> GhosttyConfig {
var config = GhosttyConfig()
@ -161,6 +216,9 @@ struct GhosttyConfig {
)
}
config.resolveSidebarBackground(preferredColorScheme: preferredColorScheme)
config.applySidebarAppearanceToUserDefaults()
return config
}
@ -240,6 +298,12 @@ struct GhosttyConfig {
if let color = NSColor(hex: value) {
splitDividerColor = color
}
case "sidebar-background":
rawSidebarBackground = value
case "sidebar-tint-opacity":
if let opacity = Double(value) {
sidebarTintOpacity = min(max(opacity, 0), 1)
}
default:
break
}

View file

@ -1923,7 +1923,15 @@ class GhosttyApp {
let tabId = tabManager.selectedTabId else {
return false
}
let tabTitle = tabManager.titleForTab(tabId) ?? "Terminal"
// Suppress OSC notifications for workspaces with active Claude hook sessions.
// The hook system manages notifications with proper lifecycle tracking;
// raw OSC notifications would duplicate or outlive the structured hooks.
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager
if let workspace = owningManager.tabs.first(where: { $0.id == tabId }),
workspace.agentPIDs["claude_code"] != nil {
return true
}
let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal"
let command = actionTitle.isEmpty ? tabTitle : actionTitle
let body = actionBody
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
@ -2195,7 +2203,13 @@ class GhosttyApp {
let actionBody = action.action.desktop_notification.body
.flatMap { String(cString: $0) } ?? ""
performOnMain {
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
// Suppress OSC notifications for workspaces with active Claude hook sessions.
let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager
if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }),
workspace.agentPIDs["claude_code"] != nil {
return
}
let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal"
let command = actionTitle.isEmpty ? tabTitle : actionTitle
let body = actionBody
TerminalNotificationStore.shared.addNotification(
@ -3872,6 +3886,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
source: "surface.viewDidMoveToWindow"
)
applyWindowBackgroundIfActive()
invalidateTextInputCoordinates()
}
override func viewDidChangeEffectiveAppearance() {
@ -3903,11 +3918,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
CATransaction.commit()
}
updateSurfaceSize()
invalidateTextInputCoordinates()
}
override func layout() {
super.layout()
updateSurfaceSize()
invalidateTextInputCoordinates()
}
override var isOpaque: Bool { false }
@ -4456,16 +4473,49 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
override func accessibilitySelectedText() -> String? {
guard let surface = surface else { return nil }
guard let snapshot = readSelectionSnapshot() else { return nil }
return snapshot.string.isEmpty ? nil : snapshot.string
}
private func readSelectionSnapshot() -> SelectionSnapshot? {
guard let surface else { return nil }
var text = ghostty_text_s()
guard ghostty_surface_read_selection(surface, &text) else { return nil }
defer { ghostty_surface_free_text(surface, &text) }
guard let ptr = text.text, text.text_len > 0 else { return nil }
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
let selected = String(decoding: selectedData, as: UTF8.self)
return selected.isEmpty ? nil : selected
let selected: String
if let ptr = text.text, text.text_len > 0 {
let selectedData = Data(bytes: ptr, count: Int(text.text_len))
selected = String(decoding: selectedData, as: UTF8.self)
} else {
selected = ""
}
return SelectionSnapshot(
range: NSRange(location: Int(text.offset_start), length: Int(text.offset_len)),
string: selected,
topLeft: CGPoint(x: text.tl_px_x, y: text.tl_px_y)
)
}
private func visibleDocumentRectInScreenCoordinates() -> NSRect {
let localRect = visibleRect
let windowRect = convert(localRect, to: nil)
guard let window else { return windowRect }
return window.convertToScreen(windowRect)
}
private func invalidateTextInputCoordinates(selectionChanged: Bool = false) {
guard let inputContext else { return }
inputContext.invalidateCharacterCoordinates()
guard selectionChanged else { return }
// `textInputClientDidUpdateSelection` is absent from the Xcode 16.2 AppKit SDK
// used by the macOS 14 compatibility lane, so call it dynamically when present.
let updateSelectionSelector = NSSelectorFromString("textInputClientDidUpdateSelection")
guard inputContext.responds(to: updateSelectionSelector) else { return }
_ = inputContext.perform(updateSelectionSelector)
}
override var acceptsFirstResponder: Bool { true }
@ -4560,6 +4610,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
private var keyTextAccumulator: [String]? = nil
private var markedText = NSMutableAttributedString()
private var lastPerformKeyEvent: TimeInterval?
private struct SelectionSnapshot {
let range: NSRange
let string: String
let topLeft: CGPoint
}
#if DEBUG
// Test-only accessors for keyTextAccumulator to verify CJK IME composition behavior.
@ -5913,7 +5968,9 @@ final class GhosttySurfaceScrollView: NSView {
private let keyboardCopyModeBadgeIconView: NSImageView
private let keyboardCopyModeBadgeLabel: NSTextField
private var searchOverlayHostingView: NSHostingView<SurfaceSearchOverlay>?
private var deferredSearchOverlayMutationWorkItem: DispatchWorkItem?
private var lastSearchOverlayStateID: ObjectIdentifier?
private var searchOverlayMutationGeneration: UInt64 = 0
private var observers: [NSObjectProtocol] = []
private var windowObservers: [NSObjectProtocol] = []
private var isLiveScrolling = false
@ -6300,6 +6357,7 @@ final class GhosttySurfaceScrollView: NSView {
#endif
observers.forEach { NotificationCenter.default.removeObserver($0) }
windowObservers.forEach { NotificationCenter.default.removeObserver($0) }
deferredSearchOverlayMutationWorkItem?.cancel()
dropZoneOverlayView.removeFromSuperview()
cancelFocusRequest()
}
@ -6386,6 +6444,9 @@ final class GhosttySurfaceScrollView: NSView {
}
_ = setFrameIfNeeded(notificationRingOverlayView, to: bounds)
_ = setFrameIfNeeded(flashOverlayView, to: bounds)
if let overlay = searchOverlayHostingView {
_ = setFrameIfNeeded(overlay, to: bounds)
}
updateNotificationRingPath()
updateFlashPath(style: .standardFocus)
synchronizeScrollView()
@ -6625,50 +6686,42 @@ final class GhosttySurfaceScrollView: NSView {
CATransaction.commit()
}
func setSearchOverlay(searchState: TerminalSurface.SearchState?) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setSearchOverlay(searchState: searchState)
}
return
private func cancelDeferredSearchOverlayMutation() {
deferredSearchOverlayMutationWorkItem?.cancel()
deferredSearchOverlayMutationWorkItem = nil
}
private func scheduleDeferredSearchOverlayMutation(
generation: UInt64,
_ mutation: @escaping () -> Void
) {
cancelDeferredSearchOverlayMutation()
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.searchOverlayMutationGeneration == generation else { return }
self.deferredSearchOverlayMutationWorkItem = nil
mutation()
}
deferredSearchOverlayMutationWorkItem = work
DispatchQueue.main.async(execute: work)
}
// Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view.
// SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces.
guard let terminalSurface = surfaceView.terminalSurface,
let searchState else {
let hadOverlay = searchOverlayHostingView != nil
lastSearchOverlayStateID = nil
guard hadOverlay else { return }
#if DEBUG
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
#endif
searchOverlayHostingView?.removeFromSuperview()
searchOverlayHostingView = nil
searchFocusTarget = .searchField
return
private func updateKeyboardCopyModeBadgeZOrder(relativeTo overlay: NSView?) {
guard !keyboardCopyModeBadgeContainerView.isHidden else { return }
if let overlay, overlay.superview === self {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
} else {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
}
}
let searchStateID = ObjectIdentifier(searchState)
if let overlay = searchOverlayHostingView,
lastSearchOverlayStateID == searchStateID,
overlay.superview === self {
if !keyboardCopyModeBadgeContainerView.isHidden {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
}
return
}
let hadOverlay = searchOverlayHostingView != nil
#if DEBUG
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
#endif
let tabId = terminalSurface.tabId
let surfaceId = terminalSurface.id
let rootView = SurfaceSearchOverlay(
tabId: tabId,
surfaceId: surfaceId,
private func makeSearchOverlayRootView(
terminalSurface: TerminalSurface,
searchState: TerminalSurface.SearchState
) -> SurfaceSearchOverlay {
SurfaceSearchOverlay(
tabId: terminalSurface.tabId,
surfaceId: terminalSurface.id,
searchState: searchState,
onMoveFocusToTerminal: { [weak self] in
self?.searchFocusTarget = .terminal
@ -6686,41 +6739,165 @@ final class GhosttySurfaceScrollView: NSView {
self?.moveFocus()
}
)
}
private func findEditableSearchField(in view: NSView?) -> NSTextField? {
guard let view else { return nil }
if let field = view as? NSTextField, field.isEditable {
return field
}
for subview in view.subviews {
if let field = findEditableSearchField(in: subview) {
return field
}
}
return nil
}
private func requestMountedSearchFieldFocus(
generation: UInt64,
force: Bool,
attemptsRemaining: Int = 4
) {
guard searchOverlayMutationGeneration == generation else { return }
guard force || searchFocusTarget == .searchField else { return }
guard let overlay = searchOverlayHostingView,
overlay.superview === self,
let window,
window.isKeyWindow else { return }
guard let field = findEditableSearchField(in: overlay) else {
guard attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
self?.requestMountedSearchFieldFocus(
generation: generation,
force: force,
attemptsRemaining: attemptsRemaining - 1
)
}
return
}
let firstResponder = window.firstResponder
let alreadyFocused = firstResponder === field ||
field.currentEditor() != nil ||
((firstResponder as? NSTextView)?.delegate as? NSTextField) === field
guard !alreadyFocused else { return }
surfaceView.terminalSurface?.setFocus(false)
let result = window.makeFirstResponder(field)
#if DEBUG
dlog(
"find.mountedFieldFocus surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") " +
"result=\(result ? 1 : 0) attemptsRemaining=\(attemptsRemaining) " +
"firstResponder=\(String(describing: window.firstResponder))"
)
#endif
guard !result, attemptsRemaining > 0 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { [weak self] in
self?.requestMountedSearchFieldFocus(
generation: generation,
force: force,
attemptsRemaining: attemptsRemaining - 1
)
}
}
func setSearchOverlay(searchState: TerminalSurface.SearchState?) {
if !Thread.isMainThread {
DispatchQueue.main.async { [weak self] in
self?.setSearchOverlay(searchState: searchState)
}
return
}
searchOverlayMutationGeneration &+= 1
let mutationGeneration = searchOverlayMutationGeneration
// Layering contract: keep terminal Cmd+F UI inside this portal-hosted AppKit view.
// SwiftUI panel-level overlays can fall behind portal-hosted terminal surfaces.
guard let terminalSurface = surfaceView.terminalSurface,
let searchState else {
let hadOverlay = searchOverlayHostingView != nil
lastSearchOverlayStateID = nil
searchFocusTarget = .searchField
guard hadOverlay else {
cancelDeferredSearchOverlayMutation()
return
}
#if DEBUG
dlog("find.setSearchOverlay REMOVE surface=\(surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil") hadOverlay=\(hadOverlay)")
#endif
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self] in
self?.searchOverlayHostingView?.removeFromSuperview()
self?.searchOverlayHostingView = nil
}
return
}
let searchStateID = ObjectIdentifier(searchState)
if let overlay = searchOverlayHostingView,
lastSearchOverlayStateID == searchStateID,
overlay.superview === self {
cancelDeferredSearchOverlayMutation()
_ = setFrameIfNeeded(overlay, to: bounds)
updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
return
}
let hadOverlay = searchOverlayHostingView != nil
#if DEBUG
dlog("find.setSearchOverlay MOUNT surface=\(terminalSurface.id.uuidString.prefix(5)) existingOverlay=\(hadOverlay ? "yes(update)" : "no(create)")")
#endif
let rootView = makeSearchOverlayRootView(
terminalSurface: terminalSurface,
searchState: searchState
)
if let overlay = searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== self {
overlay.removeFromSuperview()
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
}
if !keyboardCopyModeBadgeContainerView.isHidden {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
}
lastSearchOverlayStateID = searchStateID
if overlay.superview !== self {
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in
guard let self, let overlay else { return }
overlay.removeFromSuperview()
overlay.frame = self.bounds
overlay.autoresizingMask = [.width, .height]
self.addSubview(overlay)
self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
self.requestMountedSearchFieldFocus(
generation: mutationGeneration,
force: false
)
}
return
}
cancelDeferredSearchOverlayMutation()
_ = setFrameIfNeeded(overlay, to: bounds)
updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
return
}
searchFocusTarget = .searchField
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
addSubview(overlay)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: topAnchor),
overlay.bottomAnchor.constraint(equalTo: bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: trailingAnchor),
])
if !keyboardCopyModeBadgeContainerView.isHidden {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
}
overlay.frame = bounds
overlay.autoresizingMask = [.width, .height]
searchOverlayHostingView = overlay
lastSearchOverlayStateID = searchStateID
scheduleDeferredSearchOverlayMutation(generation: mutationGeneration) { [weak self, weak overlay] in
guard let self, let overlay else { return }
guard self.searchOverlayHostingView === overlay else { return }
overlay.removeFromSuperview()
overlay.frame = self.bounds
overlay.autoresizingMask = [.width, .height]
self.addSubview(overlay)
self.updateKeyboardCopyModeBadgeZOrder(relativeTo: overlay)
self.requestMountedSearchFieldFocus(
generation: mutationGeneration,
force: true
)
}
}
func syncKeyStateIndicator(text: String?) {
@ -6739,11 +6916,7 @@ final class GhosttySurfaceScrollView: NSView {
|| subviews.last !== keyboardCopyModeBadgeContainerView
keyboardCopyModeBadgeContainerView.isHidden = false
if needsReorder {
if let overlay = searchOverlayHostingView {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: overlay)
} else {
addSubview(keyboardCopyModeBadgeContainerView, positioned: .above, relativeTo: nil)
}
updateKeyboardCopyModeBadgeZOrder(relativeTo: searchOverlayHostingView)
}
return
}
@ -7463,6 +7636,17 @@ final class GhosttySurfaceScrollView: NSView {
let surfaceShort = surfaceView.terminalSurface?.id.uuidString.prefix(5) ?? "nil"
switch searchFocusTarget {
case .searchField:
if let firstResponder = window.firstResponder,
isCurrentSurfaceSearchFieldResponder(firstResponder) {
surfaceView.terminalSurface?.setFocus(false)
#if DEBUG
dlog(
"find.restoreSearchFocus.skip surface=\(surfaceShort) target=searchField " +
"reason=alreadyFocused firstResponder=\(String(describing: firstResponder))"
)
#endif
return
}
if let firstResponder = window.firstResponder,
isSearchOverlayOrDescendant(firstResponder),
!isCurrentSurfaceSearchResponder(firstResponder) {
@ -7666,6 +7850,17 @@ final class GhosttySurfaceScrollView: NSView {
return view.isDescendant(of: self)
}
private func isCurrentSurfaceSearchFieldResponder(_ responder: NSResponder) -> Bool {
if let editor = responder as? NSTextView,
editor.isFieldEditor,
let editedView = editor.delegate as? NSTextField {
return editedView.isDescendant(of: self) && isSearchOverlayOrDescendant(editedView)
}
guard let textField = responder as? NSTextField else { return false }
return textField.isDescendant(of: self) && isSearchOverlayOrDescendant(textField)
}
#if DEBUG
struct DebugRenderStats {
let drawCount: Int
@ -8121,7 +8316,7 @@ extension GhosttyNSView: NSTextInputClient {
}
func selectedRange() -> NSRange {
return NSRange(location: NSNotFound, length: 0)
readSelectionSnapshot()?.range ?? NSRange(location: 0, length: 0)
}
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
@ -8149,6 +8344,7 @@ extension GhosttyNSView: NSTextInputClient {
// while composing.
if keyTextAccumulator == nil {
syncPreedit()
invalidateTextInputCoordinates(selectionChanged: true)
}
}
@ -8167,6 +8363,7 @@ extension GhosttyNSView: NSTextInputClient {
if markedText.length > 0 {
markedText.mutableString.setString("")
syncPreedit()
invalidateTextInputCoordinates(selectionChanged: true)
}
}
@ -8207,11 +8404,14 @@ extension GhosttyNSView: NSTextInputClient {
}
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
return nil
guard range.length > 0,
let snapshot = readSelectionSnapshot() else { return nil }
actualRange?.pointee = snapshot.range
return NSAttributedString(string: snapshot.string)
}
func characterIndex(for point: NSPoint) -> Int {
return 0
return selectedRange().location
}
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
@ -8225,7 +8425,12 @@ extension GhosttyNSView: NSTextInputClient {
var w: Double = cellSize.width
var h: Double = cellSize.height
#if DEBUG
if let override = imePointOverrideForTesting {
if range.length > 0,
range != selectedRange(),
let snapshot = readSelectionSnapshot() {
x = snapshot.topLeft.x - 2
y = snapshot.topLeft.y + 2
} else if let override = imePointOverrideForTesting {
x = override.x
y = override.y
w = override.width
@ -8234,11 +8439,21 @@ extension GhosttyNSView: NSTextInputClient {
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
}
#else
if let surface = surface {
if range.length > 0,
range != selectedRange(),
let snapshot = readSelectionSnapshot() {
x = snapshot.topLeft.x - 2
y = snapshot.topLeft.y + 2
} else if let surface = surface {
ghostty_surface_ime_point(surface, &x, &y, &w, &h)
}
#endif
if range.length == 0, w > 0 {
// Dictation expects a caret rect for insertion points rather than a box.
w = 0
}
// Ghostty coordinates are top-left origin; AppKit expects bottom-left.
let viewRect = NSRect(
x: x,
@ -8250,6 +8465,30 @@ extension GhosttyNSView: NSTextInputClient {
return window.convertToScreen(winRect)
}
func attributedString() -> NSAttributedString {
if markedText.length > 0 {
return NSAttributedString(attributedString: markedText)
}
if let snapshot = readSelectionSnapshot(), !snapshot.string.isEmpty {
return NSAttributedString(string: snapshot.string)
}
return NSAttributedString(string: "")
}
func windowLevel() -> Int {
Int(window?.level.rawValue ?? NSWindow.Level.normal.rawValue)
}
@available(macOS 14.0, *)
var unionRectInVisibleSelectedRange: NSRect {
firstRect(forCharacterRange: selectedRange(), actualRange: nil)
}
@available(macOS 14.0, *)
var documentVisibleRect: NSRect {
visibleDocumentRectInScreenCoordinates()
}
func insertText(_ string: Any, replacementRange: NSRange) {
#if DEBUG
let typingTimingStart = CmuxTypingTiming.start()

File diff suppressed because it is too large Load diff

View file

@ -203,9 +203,39 @@ func resolvedBrowserOmnibarPillBackgroundColor(
return themeBackgroundColor.blended(withFraction: darkenMix, of: .black) ?? themeBackgroundColor
}
private struct BrowserChromeStyle {
let backgroundColor: NSColor
let colorScheme: ColorScheme
let omnibarPillBackgroundColor: NSColor
static func resolve(
for colorScheme: ColorScheme,
themeBackgroundColor: NSColor
) -> BrowserChromeStyle {
let backgroundColor = resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: themeBackgroundColor
)
let chromeColorScheme = resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: backgroundColor
)
let omnibarPillBackgroundColor = resolvedBrowserOmnibarPillBackgroundColor(
for: chromeColorScheme,
themeBackgroundColor: backgroundColor
)
return BrowserChromeStyle(
backgroundColor: backgroundColor,
colorScheme: chromeColorScheme,
omnibarPillBackgroundColor: omnibarPillBackgroundColor
)
}
}
/// View for rendering a browser panel with address bar
struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
@ObservedObject private var browserProfileStore = BrowserProfileStore.shared
let paneId: PaneID
let isFocused: Bool
let isVisibleInUI: Bool
@ -220,10 +250,15 @@ struct BrowserPanelView: View {
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@AppStorage(BrowserThemeSettings.modeKey) private var browserThemeModeRaw = BrowserThemeSettings.defaultMode.rawValue
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@State private var suggestionTask: Task<Void, Never>?
@State private var isLoadingRemoteSuggestions: Bool = false
@State private var latestRemoteSuggestionQuery: String = ""
@State private var latestRemoteSuggestions: [String] = []
@State private var emptyStateImportBrowsers: [InstalledBrowserCandidate] = []
@State private var emptyStateImportBrowserRefreshTask: Task<Void, Never>?
@State private var emptyStateImportBrowserRefreshGeneration: UInt64 = 0
@State private var inlineCompletion: OmnibarInlineCompletion?
@State private var omnibarSelectionRange: NSRange = NSRange(location: NSNotFound, length: 0)
@State private var omnibarHasMarkedText: Bool = false
@ -235,8 +270,13 @@ struct BrowserPanelView: View {
@State private var lastHandledAddressBarFocusRequestId: UUID?
@State private var pendingAddressBarFocusRetryRequestId: UUID?
@State private var pendingAddressBarFocusRetryGeneration: UInt64 = 0
@State private var isBrowserProfileMenuPresented = false
@State private var isBrowserThemeMenuPresented = false
@State private var ghosttyBackgroundGeneration: Int = 0
@State private var browserChromeStyle = BrowserChromeStyle.resolve(
for: .light,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
@State private var toggleBrowserDeveloperToolsShortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
// Keep this below half of the compact omnibar height so it reads as a squircle,
// not a capsule.
private let omnibarPillCornerRadius: CGFloat = 10
@ -282,24 +322,15 @@ struct BrowserPanelView: View {
}
private var browserChromeBackground: Color {
_ = ghosttyBackgroundGeneration
return Color(nsColor: GhosttyBackgroundTheme.currentColor())
Color(nsColor: browserChromeStyle.backgroundColor)
}
private var browserChromeBackgroundColor: NSColor {
_ = ghosttyBackgroundGeneration
return resolvedBrowserChromeBackgroundColor(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
browserChromeStyle.backgroundColor
}
private var browserChromeColorScheme: ColorScheme {
_ = ghosttyBackgroundGeneration
return resolvedBrowserChromeColorScheme(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
browserChromeStyle.colorScheme
}
private var browserContentAccessibilityIdentifier: String {
@ -307,10 +338,12 @@ struct BrowserPanelView: View {
}
private var omnibarPillBackgroundColor: NSColor {
resolvedBrowserOmnibarPillBackgroundColor(
for: browserChromeColorScheme,
themeBackgroundColor: browserChromeBackgroundColor
)
browserChromeStyle.omnibarPillBackgroundColor
}
private var developerToolsButtonHelp: String {
let base = String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools")
return "\(base) (\(toggleBrowserDeveloperToolsShortcut.displayString))"
}
private var owningWorkspace: Workspace? {
@ -420,6 +453,8 @@ struct BrowserPanelView: View {
BrowserSearchSettings.searchSuggestionsEnabledKey: BrowserSearchSettings.defaultSearchSuggestionsEnabled,
BrowserThemeSettings.modeKey: BrowserThemeSettings.defaultMode.rawValue,
])
refreshBrowserChromeStyle()
refreshToggleBrowserDeveloperToolsShortcut()
let resolvedThemeMode = BrowserThemeSettings.mode(defaults: .standard)
if browserThemeModeRaw != resolvedThemeMode.rawValue {
browserThemeModeRaw = resolvedThemeMode.rawValue
@ -431,7 +466,8 @@ struct BrowserPanelView: View {
// If the browser surface is focused but has no URL loaded yet, auto-focus the omnibar.
autoFocusOmnibarIfBlank()
syncWebViewResponderPolicyWithViewState(reason: "onAppear")
BrowserHistoryStore.shared.loadIfNeeded()
refreshEmptyStateImportBrowsers()
panel.historyStore.loadIfNeeded()
#if DEBUG
logBrowserFocusState(event: "view.onAppear")
#endif
@ -450,6 +486,9 @@ struct BrowserPanelView: View {
!isWebViewBlank() {
setAddressBarFocused(false, reason: "panel.currentURL.loaded")
}
if isWebViewBlank() {
refreshEmptyStateImportBrowsers()
}
}
.onChange(of: browserThemeModeRaw) { _ in
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
@ -459,11 +498,21 @@ struct BrowserPanelView: View {
panel.setBrowserThemeMode(normalizedMode)
}
.onChange(of: colorScheme) { _ in
refreshBrowserChromeStyle()
panel.refreshAppearanceDrivenColors()
}
.onChange(of: toggleBrowserDeveloperToolsShortcutData) { _ in
refreshToggleBrowserDeveloperToolsShortcut()
}
.onChange(of: panel.pendingAddressBarFocusRequestId) { _ in
applyPendingAddressBarFocusRequestIfNeeded()
}
.onChange(of: panel.profileID) { _ in
panel.historyStore.loadIfNeeded()
if addressBarFocused {
refreshSuggestions()
}
}
.onChange(of: isFocused) { focused in
#if DEBUG
logBrowserFocusState(
@ -536,7 +585,7 @@ struct BrowserPanelView: View {
applyOmnibarEffects(effects)
refreshInlineCompletion()
}
.onReceive(BrowserHistoryStore.shared.$entries) { _ in
.onReceive(panel.historyStore.$entries) { _ in
guard addressBarFocused else { return }
refreshSuggestions()
}
@ -552,7 +601,7 @@ struct BrowserPanelView: View {
}
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDefaultBackgroundDidChange)) { _ in
ghosttyBackgroundGeneration &+= 1
refreshBrowserChromeStyle()
}
}
@ -564,10 +613,9 @@ struct BrowserPanelView: View {
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
if !panel.isShowingNewTabPage {
browserThemeModeButton
developerToolsButton
}
browserProfileButton
browserThemeModeButton
developerToolsButton
}
.padding(.horizontal, 8)
.padding(.vertical, addressBarVerticalPadding)
@ -668,10 +716,38 @@ struct BrowserPanelView: View {
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.safeHelp(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.tooltip(String(localized: "browser.toggleDevTools", defaultValue: "Toggle Developer Tools")))
.safeHelp(developerToolsButtonHelp)
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}
private var browserProfileButton: some View {
Button(action: {
isBrowserProfileMenuPresented.toggle()
}) {
Image(systemName: "person.crop.circle")
.symbolRenderingMode(.monochrome)
.cmuxFlatSymbolColorRendering()
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(OmnibarAddressButtonStyle())
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.popover(isPresented: $isBrowserProfileMenuPresented, arrowEdge: .bottom) {
browserProfilePopover
}
.safeHelp(
String(
format: String(
localized: "browser.profile.buttonHelp",
defaultValue: "Browser Profile: %@"
),
panel.profileDisplayName
)
)
.accessibilityIdentifier("BrowserProfileButton")
}
private var browserThemeModeButton: some View {
Button(action: {
isBrowserThemeMenuPresented.toggle()
@ -688,10 +764,76 @@ struct BrowserPanelView: View {
.popover(isPresented: $isBrowserThemeMenuPresented, arrowEdge: .bottom) {
browserThemeModePopover
}
.safeHelp("Browser Theme: \(browserThemeMode.displayName)")
.safeHelp(
String(
format: String(
localized: "browser.theme.buttonHelp",
defaultValue: "Browser Theme: %@"
),
browserThemeMode.displayName
)
)
.accessibilityIdentifier("BrowserThemeModeButton")
}
private var browserProfilePopover: some View {
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "browser.profile.menu.title", defaultValue: "Profiles"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 2) {
ForEach(browserProfileStore.profiles) { profile in
Button {
applyBrowserProfileSelection(profile.id)
} label: {
HStack(spacing: 8) {
Image(systemName: profile.id == panel.profileID ? "checkmark" : "circle")
.font(.system(size: 10, weight: .semibold))
.opacity(profile.id == panel.profileID ? 1.0 : 0.0)
.frame(width: 12, alignment: .center)
Text(profile.displayName)
.font(.system(size: 12))
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.frame(height: 24)
.contentShape(Rectangle())
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(profile.id == panel.profileID ? Color.primary.opacity(0.12) : Color.clear)
)
}
.buttonStyle(.plain)
}
}
Divider()
Button {
isBrowserProfileMenuPresented = false
presentCreateBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.new", defaultValue: "New Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
if browserProfileStore.canRenameProfile(id: panel.profileID) {
Button {
isBrowserProfileMenuPresented = false
presentRenameBrowserProfilePrompt()
} label: {
Text(String(localized: "browser.profile.rename", defaultValue: "Rename Current Profile..."))
.font(.system(size: 12))
}
.buttonStyle(.plain)
}
}
.padding(8)
.frame(minWidth: 208)
}
private var browserThemeModePopover: some View {
VStack(alignment: .leading, spacing: 2) {
ForEach(BrowserThemeMode.allCases) { mode in
@ -876,6 +1018,11 @@ struct BrowserPanelView: View {
setAddressBarFocused(false, reason: "placeholderContent.tapBlur")
}
}
.overlay {
if shouldShowEmptyStateImportOverlay {
emptyBrowserStateOverlay
}
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
@ -907,6 +1054,28 @@ struct BrowserPanelView: View {
}
}
private func refreshBrowserChromeStyle() {
browserChromeStyle = BrowserChromeStyle.resolve(
for: colorScheme,
themeBackgroundColor: GhosttyBackgroundTheme.currentColor()
)
}
private func refreshToggleBrowserDeveloperToolsShortcut() {
toggleBrowserDeveloperToolsShortcut = decodeShortcut(
from: toggleBrowserDeveloperToolsShortcutData,
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
)
}
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
guard !data.isEmpty,
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
return fallback
}
return shortcut
}
private func syncWebViewResponderPolicyWithViewState(
reason: String,
isPanelFocusedOverride: Bool? = nil
@ -1119,6 +1288,51 @@ struct BrowserPanelView: View {
#endif
}
private var emptyBrowserStateOverlay: some View {
VStack {
Spacer(minLength: 22)
VStack(alignment: .leading, spacing: 8) {
Text(String(localized: "settings.browser.emptyImport.title", defaultValue: "Import browser data"))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
Text(InstalledBrowserDetector.summaryText(for: emptyStateImportBrowsers))
.font(.system(size: 12))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button(String(localized: "settings.browser.emptyImport.choose", defaultValue: "Choose What to Import…")) {
BrowserDataImportCoordinator.shared.presentImportDialog(
defaultDestinationProfileID: panel.profileID
)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(12)
.frame(maxWidth: 360, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.9))
)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(
Color(nsColor: .separatorColor).opacity(0.45),
lineWidth: 1
)
)
.shadow(color: Color.black.opacity(0.08), radius: 8, y: 3)
Spacer()
}
.padding(.horizontal, 18)
}
private var shouldShowEmptyStateImportOverlay: Bool {
!panel.shouldRenderWebView && isWebViewBlank()
}
/// Treat a WebView with no URL (or about:blank) as "blank" for UX purposes.
private func isWebViewBlank() -> Bool {
guard let url = panel.webView.url else { return true }
@ -1170,6 +1384,31 @@ struct BrowserPanelView: View {
#endif
}
private func refreshEmptyStateImportBrowsers() {
emptyStateImportBrowserRefreshTask?.cancel()
emptyStateImportBrowserRefreshGeneration &+= 1
let generation = emptyStateImportBrowserRefreshGeneration
guard shouldShowEmptyStateImportOverlay else {
emptyStateImportBrowsers = []
emptyStateImportBrowserRefreshTask = nil
return
}
emptyStateImportBrowserRefreshTask = Task {
let browsers = await Task.detached(priority: .utility) {
InstalledBrowserDetector.detectInstalledBrowsers()
}.value
guard !Task.isCancelled else { return }
await MainActor.run {
guard emptyStateImportBrowserRefreshGeneration == generation,
shouldShowEmptyStateImportOverlay else { return }
emptyStateImportBrowsers = browsers
emptyStateImportBrowserRefreshTask = nil
}
}
}
private func openDevTools() {
#if DEBUG
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
@ -1273,10 +1512,73 @@ struct BrowserPanelView: View {
let target = omnibarState.suggestions[idx]
guard case .history(let url, _) = target.kind else { return }
guard BrowserHistoryStore.shared.removeHistoryEntry(urlString: url) else { return }
guard panel.historyStore.removeHistoryEntry(urlString: url) else { return }
refreshSuggestions()
}
private func applyBrowserProfileSelection(_ profileID: UUID) {
isBrowserProfileMenuPresented = false
owningWorkspace?.setPreferredBrowserProfileID(profileID)
_ = panel.switchToProfile(profileID)
}
private func presentCreateBrowserProfilePrompt() {
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.new.title", defaultValue: "New Browser Profile")
alert.informativeText = String(localized: "browser.profile.new.message", defaultValue: "Create a separate browser profile for cookies, history, and local storage.")
let input = NSTextField(string: "")
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.create", defaultValue: "Create"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn,
let profile = browserProfileStore.createProfile(named: input.stringValue) else {
return
}
applyBrowserProfileSelection(profile.id)
}
private func presentRenameBrowserProfilePrompt() {
guard let profile = browserProfileStore.profileDefinition(id: panel.profileID),
browserProfileStore.canRenameProfile(id: profile.id) else {
return
}
let alert = NSAlert()
alert.messageText = String(localized: "browser.profile.rename.title", defaultValue: "Rename Browser Profile")
alert.informativeText = String(localized: "browser.profile.rename.message", defaultValue: "Choose a new name for this browser profile.")
let input = NSTextField(string: profile.displayName)
input.placeholderString = String(localized: "browser.profile.new.placeholder", defaultValue: "Profile name")
input.frame = NSRect(x: 0, y: 0, width: 260, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: String(localized: "common.rename", defaultValue: "Rename"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
guard alert.runModal() == .alertFirstButtonReturn else { return }
_ = browserProfileStore.renameProfile(id: profile.id, to: input.stringValue)
}
private func refreshInlineCompletion() {
inlineCompletion = omnibarInlineCompletionForDisplay(
typedText: omnibarState.buffer,
@ -1312,9 +1614,9 @@ struct BrowserPanelView: View {
let query = omnibarState.buffer.trimmingCharacters(in: .whitespacesAndNewlines)
let historyEntries: [BrowserHistoryStore.Entry] = {
if query.isEmpty {
return BrowserHistoryStore.shared.recentSuggestions(limit: 12)
return panel.historyStore.recentSuggestions(limit: 12)
}
return BrowserHistoryStore.shared.suggestions(for: query, limit: 12)
return panel.historyStore.suggestions(for: query, limit: 12)
}()
let openTabMatches = query.isEmpty ? [] : matchingOpenTabSuggestions(for: query, limit: 12)
let isSingleCharacterQuery = omnibarSingleCharacterQuery(for: query) != nil
@ -1378,7 +1680,7 @@ struct BrowserPanelView: View {
let merged = buildOmnibarSuggestions(
query: query,
engineName: searchEngine.displayName,
historyEntries: BrowserHistoryStore.shared.suggestions(for: query, limit: 12),
historyEntries: panel.historyStore.suggestions(for: query, limit: 12),
openTabMatches: matchingOpenTabSuggestions(for: query, limit: 12),
remoteQueries: remote,
resolvedURL: panel.resolveNavigableURL(from: query),

View file

@ -0,0 +1,619 @@
import AppKit
import Bonsplit
import ObjectiveC
import WebKit
func browserPopupContentRect(
requestedWidth: CGFloat?,
requestedHeight: CGFloat?,
requestedX: CGFloat?,
requestedTopY: CGFloat?,
visibleFrame: NSRect,
defaultWidth: CGFloat = 800,
defaultHeight: CGFloat = 600,
minWidth: CGFloat = 200,
minHeight: CGFloat = 150
) -> NSRect {
let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width)
let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height)
let x: CGFloat
let y: CGFloat
if let requestedX, let requestedTopY {
x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth))
// Web content expresses popup Y as distance from the screen's top edge,
// while AppKit window origins are bottom-up.
let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight
y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight))
} else {
x = visibleFrame.midX - clampedWidth / 2
y = visibleFrame.midY - clampedHeight / 2
}
return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight)
}
/// Hosts a popup `CmuxWebView` in a standalone `NSPanel`, created when a page
/// calls `window.open()` (scripted new-window requests).
///
/// Lifecycle:
/// - The controller self-retains via `objc_setAssociatedObject` on its panel.
/// - Released in `windowWillClose(_:)` when the panel closes.
/// - The opener `BrowserPanel` also keeps a strong reference for deterministic
/// cleanup when the opener tab or workspace is closed.
/// NSPanel subclass that intercepts Cmd+W before the swizzled
/// `cmux_performKeyEquivalent` can dispatch it to the main menu's
/// "Close Tab" action (which would close the parent browser tab).
private class BrowserPopupPanel: NSPanel {
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Cmd+W: close this popup panel only
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags == .command,
event.charactersIgnoringModifiers == "w" {
#if DEBUG
dlog("popup.panel.cmdW close")
#endif
performClose(nil)
return true
}
return super.performKeyEquivalent(with: event)
}
}
@MainActor
final class BrowserPopupWindowController: NSObject, NSWindowDelegate {
static let maxNestingDepth = 3
let webView: CmuxWebView
private let panel: NSPanel
private let urlLabel: NSTextField
private weak var openerPanel: BrowserPanel?
private weak var parentPopupController: BrowserPopupWindowController?
private let nestingDepth: Int
private var titleObservation: NSKeyValueObservation?
private var urlObservation: NSKeyValueObservation?
private var childPopups: [BrowserPopupWindowController] = []
private let popupUIDelegate: PopupUIDelegate
private let popupNavigationDelegate: PopupNavigationDelegate
private let downloadDelegate: BrowserDownloadDelegate
private static var associatedObjectKey: UInt8 = 0
init(
configuration: WKWebViewConfiguration,
windowFeatures: WKWindowFeatures,
openerPanel: BrowserPanel?,
parentPopupController: BrowserPopupWindowController? = nil,
nestingDepth: Int = 0
) {
self.openerPanel = openerPanel
self.parentPopupController = parentPopupController
self.nestingDepth = nestingDepth
// Create popup web view with WebKit's supplied configuration (preserves
// internal browsing-context state for opener linkage / postMessage).
let webView = CmuxWebView(frame: .zero, configuration: configuration)
webView.allowsBackForwardNavigationGestures = true
if #available(macOS 13.3, *) {
webView.isInspectable = true
}
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
self.webView = webView
// --- Window sizing from WKWindowFeatures ---
let defaultWidth: CGFloat = 800
let defaultHeight: CGFloat = 600
let minWidth: CGFloat = 200
let minHeight: CGFloat = 150
let w = max(windowFeatures.width?.doubleValue ?? defaultWidth, minWidth)
let h = max(windowFeatures.height?.doubleValue ?? defaultHeight, minHeight)
// Screen-clamping: use opener's screen or main screen
let screen = openerPanel?.webView.window?.screen ?? NSScreen.main ?? NSScreen.screens.first
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
let contentRect = browserPopupContentRect(
requestedWidth: w,
requestedHeight: h,
requestedX: windowFeatures.x.map { CGFloat($0.doubleValue) },
requestedTopY: windowFeatures.y.map { CGFloat($0.doubleValue) },
visibleFrame: visibleFrame,
defaultWidth: defaultWidth,
defaultHeight: defaultHeight,
minWidth: minWidth,
minHeight: minHeight
)
// Style mask: titled + closable + resizable by default.
// allowsResizing is a separate property from chrome-visibility flags
// (toolbarsVisibility, menuBarVisibility, statusBarVisibility).
var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable]
if windowFeatures.allowsResizing?.boolValue != false {
styleMask.insert(.resizable)
}
let panel = BrowserPopupPanel(
contentRect: contentRect,
styleMask: styleMask,
backing: .buffered,
defer: false
)
panel.identifier = NSUserInterfaceItemIdentifier("cmux.browser-popup")
panel.level = NSWindow.Level.normal
panel.hidesOnDeactivate = false
panel.isReleasedWhenClosed = false
panel.minSize = NSSize(width: minWidth, height: minHeight)
panel.title = String(localized: "browser.popup.loadingTitle", defaultValue: "Loading\u{2026}")
self.panel = panel
let urlLabel = NSTextField(labelWithString: "")
self.urlLabel = urlLabel
// Build delegate objects before super.init so they can be assigned
let uiDel = PopupUIDelegate()
let navDel = PopupNavigationDelegate()
let dlDel = BrowserDownloadDelegate()
self.popupUIDelegate = uiDel
self.popupNavigationDelegate = navDel
self.downloadDelegate = dlDel
super.init()
// --- URL label for phishing protection ---
urlLabel.translatesAutoresizingMaskIntoConstraints = false
urlLabel.font = .systemFont(ofSize: 11)
urlLabel.textColor = .secondaryLabelColor
urlLabel.lineBreakMode = .byTruncatingMiddle
urlLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let containerView = NSView()
containerView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(urlLabel)
containerView.addSubview(webView)
webView.translatesAutoresizingMaskIntoConstraints = false
panel.contentView = containerView
NSLayoutConstraint.activate([
urlLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4),
urlLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8),
urlLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8),
urlLabel.heightAnchor.constraint(equalToConstant: 16),
webView.topAnchor.constraint(equalTo: urlLabel.bottomAnchor, constant: 2),
webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
// --- Delegates ---
uiDel.controller = self
navDel.controller = self
navDel.downloadDelegate = dlDel
webView.uiDelegate = uiDel
webView.navigationDelegate = navDel
// Context menu "Open Link in New Tab" open in opener's workspace,
// not as a nested popup. Falls back to system browser if opener is gone.
webView.onContextMenuOpenLinkInNewTab = { [weak self] url in
if let opener = self?.openerPanel {
opener.openLinkInNewTab(url: url)
} else {
NSWorkspace.shared.open(url)
}
}
// --- KVO for title and URL ---
titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, change in
guard let newTitle = change.newValue ?? nil, !newTitle.isEmpty else { return }
Task { @MainActor [weak self] in
self?.panel.title = newTitle
}
}
urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in
let displayURL = change.newValue??.absoluteString ?? ""
Task { @MainActor [weak self] in
self?.urlLabel.stringValue = displayURL
}
}
// --- Self-retention via associated object on panel ---
objc_setAssociatedObject(panel, &Self.associatedObjectKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
panel.delegate = self
#if DEBUG
dlog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")")
#endif
panel.makeKeyAndOrderFront(self)
}
// MARK: - Child popup tracking
func addChildPopup(_ child: BrowserPopupWindowController) {
childPopups.append(child)
}
func removeChildPopup(_ child: BrowserPopupWindowController) {
childPopups.removeAll { $0 === child }
}
// MARK: - Popup lifecycle
func closePopup() {
panel.close() // triggers windowWillClose
}
func closeAllChildPopups() {
let children = childPopups
childPopups.removeAll()
for child in children {
child.closeAllChildPopups()
child.closePopup()
}
}
// MARK: - NSWindowDelegate
func windowWillClose(_ notification: Notification) {
#if DEBUG
dlog("popup.close depth=\(nestingDepth)")
#endif
closeAllChildPopups()
// Invalidate observations
titleObservation?.invalidate()
titleObservation = nil
urlObservation?.invalidate()
urlObservation = nil
// Tear down web view
webView.stopLoading()
webView.navigationDelegate = nil
webView.uiDelegate = nil
// Unregister from parent (opener panel or parent popup)
openerPanel?.removePopupController(self)
parentPopupController?.removeChildPopup(self)
// Release self-retention
objc_setAssociatedObject(panel, &Self.associatedObjectKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
}
// MARK: - Nested popup creation
func createNestedPopup(
configuration: WKWebViewConfiguration,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
let nextDepth = nestingDepth + 1
if nextDepth > Self.maxNestingDepth {
#if DEBUG
dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)")
#endif
return nil
}
let child = BrowserPopupWindowController(
configuration: configuration,
windowFeatures: windowFeatures,
openerPanel: openerPanel,
parentPopupController: self,
nestingDepth: nextDepth
)
addChildPopup(child)
return child.webView
}
func openInOpenerTab(_ url: URL) {
if let openerPanel {
openerPanel.openLinkInNewTab(url: url)
} else {
NSWorkspace.shared.open(url)
}
}
// MARK: - Insecure HTTP prompt (parity with main browser)
/// Shows the same 3-button insecure HTTP alert as the main browser.
/// Reuses the global helpers from BrowserPanel.swift.
fileprivate func presentInsecureHTTPAlert(
for url: URL,
in webView: WKWebView,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
decisionHandler(.cancel)
return
}
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure")
alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux.")
alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser"))
alert.addButton(withTitle: String(localized: "browser.proceedInCmux", defaultValue: "Proceed in cmux"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
alert.showsSuppressionButton = true
alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in cmux")
let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak alert] response in
if browserShouldPersistInsecureHTTPAllowlistSelection(
response: response,
suppressionEnabled: alert?.suppressionButton?.state == .on
) {
BrowserInsecureHTTPSettings.addAllowedHost(host)
}
switch response {
case .alertFirstButtonReturn:
// Open in default browser, cancel popup navigation
NSWorkspace.shared.open(url)
decisionHandler(.cancel)
case .alertSecondButtonReturn:
// Proceed in popup
decisionHandler(.allow)
default:
decisionHandler(.cancel)
}
}
if let window = webView.window {
alert.beginSheetModal(for: window, completionHandler: handleResponse)
return
}
handleResponse(alert.runModal())
}
}
// MARK: - PopupUIDelegate
private class PopupUIDelegate: NSObject, WKUIDelegate {
weak var controller: BrowserPopupWindowController?
func webViewDidClose(_ webView: WKWebView) {
#if DEBUG
dlog("popup.webViewDidClose")
#endif
controller?.closePopup()
}
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
// External URL check
if let url = navigationAction.request.url,
browserShouldOpenURLExternally(url) {
NSWorkspace.shared.open(url)
return nil
}
let isScriptedPopup = browserNavigationShouldCreatePopup(
navigationType: navigationAction.navigationType,
modifierFlags: navigationAction.modifierFlags,
buttonNumber: navigationAction.buttonNumber,
hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView)
)
if isScriptedPopup {
return controller?.createNestedPopup(
configuration: configuration,
windowFeatures: windowFeatures
)
}
if let url = navigationAction.request.url {
controller?.openInOpenerTab(url)
}
return nil
}
// MARK: - JS Dialogs (parity with main browser)
private func javaScriptDialogTitle(for webView: WKWebView) -> String {
if let absolute = webView.url?.absoluteString, !absolute.isEmpty {
return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:")
}
return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:")
}
private func presentDialog(
_ alert: NSAlert,
for webView: WKWebView,
completion: @escaping (NSApplication.ModalResponse) -> Void
) {
if let window = webView.window {
alert.beginSheetModal(for: window, completionHandler: completion)
return
}
completion(alert.runModal())
}
func webView(
_ webView: WKWebView,
runJavaScriptAlertPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping () -> Void
) {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = javaScriptDialogTitle(for: webView)
alert.informativeText = message
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
presentDialog(alert, for: webView) { _ in completionHandler() }
}
func webView(
_ webView: WKWebView,
runJavaScriptConfirmPanelWithMessage message: String,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (Bool) -> Void
) {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = javaScriptDialogTitle(for: webView)
alert.informativeText = message
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
presentDialog(alert, for: webView) { response in
completionHandler(response == .alertFirstButtonReturn)
}
}
func webView(
_ webView: WKWebView,
runJavaScriptTextInputPanelWithPrompt prompt: String,
defaultText: String?,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping (String?) -> Void
) {
let alert = NSAlert()
alert.alertStyle = .informational
alert.messageText = javaScriptDialogTitle(for: webView)
alert.informativeText = prompt
alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK"))
alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel"))
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
field.stringValue = defaultText ?? ""
alert.accessoryView = field
presentDialog(alert, for: webView) { response in
if response == .alertFirstButtonReturn {
completionHandler(field.stringValue)
} else {
completionHandler(nil)
}
}
}
func webView(
_ webView: WKWebView,
runOpenPanelWith parameters: WKOpenPanelParameters,
initiatedByFrame frame: WKFrameInfo,
completionHandler: @escaping ([URL]?) -> Void
) {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
panel.canChooseDirectories = parameters.allowsDirectories
panel.canChooseFiles = true
panel.begin { result in
completionHandler(result == .OK ? panel.urls : nil)
}
}
func webView(
_ webView: WKWebView,
requestMediaCapturePermissionFor origin: WKSecurityOrigin,
initiatedByFrame frame: WKFrameInfo,
type: WKMediaCaptureType,
decisionHandler: @escaping (WKPermissionDecision) -> Void
) {
decisionHandler(.prompt)
}
}
// MARK: - PopupNavigationDelegate
private class PopupNavigationDelegate: NSObject, WKNavigationDelegate {
weak var controller: BrowserPopupWindowController?
var downloadDelegate: WKDownloadDelegate?
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
// Only guard main-frame navigations
guard navigationAction.targetFrame?.isMainFrame != false else {
decisionHandler(.allow)
return
}
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
// External URL schemes hand off to macOS
if browserShouldOpenURLExternally(url) {
NSWorkspace.shared.open(url)
#if DEBUG
dlog("popup.nav.external url=\(url.absoluteString)")
#endif
decisionHandler(.cancel)
return
}
// Insecure HTTP show same prompt as main browser
if browserShouldBlockInsecureHTTPURL(url) {
#if DEBUG
dlog("popup.nav.insecureHTTP url=\(url.absoluteString)")
#endif
controller?.presentInsecureHTTPAlert(for: url, in: webView, decisionHandler: decisionHandler)
return
}
decisionHandler(.allow)
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
) {
if !navigationResponse.isForMainFrame {
decisionHandler(.allow)
return
}
if let scheme = navigationResponse.response.url?.scheme?.lowercased(),
scheme != "http", scheme != "https" {
decisionHandler(.allow)
return
}
if let response = navigationResponse.response as? HTTPURLResponse {
let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? ""
if contentDisposition.lowercased().hasPrefix("attachment") {
decisionHandler(.download)
return
}
}
if !navigationResponse.canShowMIMEType {
decisionHandler(.download)
return
}
decisionHandler(.allow)
}
func webView(
_ webView: WKWebView,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
// Parity with main browser: performDefaultHandling enables system keychain
// lookups, MDM client certs, and SSO extensions (e.g. Microsoft Entra ID).
completionHandler(.performDefaultHandling, nil)
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
#if DEBUG
dlog("popup.download.didBecome source=navigationAction")
#endif
download.delegate = downloadDelegate
}
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
#if DEBUG
dlog("popup.download.didBecome source=navigationResponse")
#endif
download.delegate = downloadDelegate
}
}

View file

@ -53,6 +53,9 @@ final class CmuxWebView: WKWebView {
private static var contextMenuFallbackKey: UInt8 = 0
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
/// Called when "Open Link in New Tab" context menu is selected.
/// Bypasses createWebViewWith so the link opens as a tab, not a popup.
var onContextMenuOpenLinkInNewTab: ((URL) -> Void)?
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
/// Guard against background panes stealing first responder (e.g. page autofocus).
@ -1212,12 +1215,15 @@ final class CmuxWebView: WKWebView {
openLinkInsertionIndex = index + 1
}
// Rename "Open Link in New Window" to "Open Link in New Tab".
// The UIDelegate's createWebViewWith already handles the action
// by opening the link as a new surface in the same pane.
// Retarget "Open Link in New Window" to open as a tab, not a popup.
// Without this, WebKit's default action calls createWebViewWith with
// navigationType .other, which our classifier would treat as a scripted
// popup request.
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|| item.title.contains("Open Link in New Window") {
item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab")
item.target = self
item.action = #selector(contextMenuOpenLinkInNewTab(_:))
}
if isDownloadImageMenuItem(item) {
@ -1275,6 +1281,14 @@ final class CmuxWebView: WKWebView {
}
}
@objc private func contextMenuOpenLinkInNewTab(_ sender: Any?) {
let point = lastContextMenuPoint
resolveContextMenuLinkURL(at: point) { [weak self] url in
guard let self, let url else { return }
self.onContextMenuOpenLinkInNewTab?(url)
}
}
@objc private func contextMenuDownloadImage(_ sender: Any?) {
let traceID = Self.makeContextDownloadTraceID(prefix: "img")
let point = lastContextMenuPoint

View file

@ -8,7 +8,7 @@ enum SessionSnapshotSchema {
enum SessionPersistencePolicy {
static let defaultSidebarWidth: Double = 200
static let minimumSidebarWidth: Double = 186
static let minimumSidebarWidth: Double = 180
static let maximumSidebarWidth: Double = 600
static let minimumWindowWidth: Double = 300
static let minimumWindowHeight: Double = 200
@ -228,6 +228,7 @@ struct SessionTerminalPanelSnapshot: Codable, Sendable {
struct SessionBrowserPanelSnapshot: Codable, Sendable {
var urlString: String?
var profileID: UUID?
var shouldRenderWebView: Bool
var pageZoom: Double
var developerToolsVisible: Bool

View file

@ -636,6 +636,15 @@ class TabManager: ObservableObject {
private struct InitialWorkspaceGitMetadataSnapshot: Equatable {
let branch: String?
let isDirty: Bool
let pullRequest: SidebarPullRequestState?
}
private struct CommandResult {
let stdout: String?
let stderr: String?
let exitStatus: Int32?
let timedOut: Bool
let executionError: String?
}
/// The window that owns this TabManager. Set by AppDelegate.registerMainWindow().
@ -651,6 +660,7 @@ class TabManager: ObservableObject {
/// Static so port ranges don't overlap across multiple windows (each window has its own TabManager).
private static var nextPortOrdinal: Int = 0
private static let initialWorkspaceGitProbeDelays: [TimeInterval] = [0, 0.5, 1.5, 3.0, 6.0, 10.0]
private nonisolated static let initialWorkspacePullRequestProbeTimeout: TimeInterval = 5.0
@Published var selectedTabId: UUID? {
willSet {
#if DEBUG
@ -760,6 +770,7 @@ class TabManager: ObservableObject {
return tabs.first(where: { $0.id == selectedTabId })
}
}
private var agentPIDSweepTimer: DispatchSourceTimer?
#if DEBUG
private var debugWorkspaceSwitchCounter: UInt64 = 0
private var debugWorkspaceSwitchId: UInt64 = 0
@ -805,6 +816,8 @@ class TabManager: ObservableObject {
}
})
startAgentPIDSweepTimer()
#if DEBUG
setupUITestFocusShortcutsIfNeeded()
setupSplitCloseRightUITestIfNeeded()
@ -815,6 +828,54 @@ class TabManager: ObservableObject {
deinit {
workspaceCycleCooldownTask?.cancel()
agentPIDSweepTimer?.cancel()
}
// MARK: - Agent PID Sweep
/// Periodically checks agent PIDs associated with status entries.
/// If a process has exited (SIGKILL, crash, etc.), clears the stale status entry.
/// This is the safety net for cases where no hook fires (e.g. SIGKILL).
private func startAgentPIDSweepTimer() {
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
timer.schedule(deadline: .now() + 30, repeating: 30)
timer.setEventHandler { [weak self] in
guard let self else { return }
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.sweepStaleAgentPIDs()
}
}
timer.resume()
agentPIDSweepTimer = timer
}
private func sweepStaleAgentPIDs() {
for tab in tabs {
var keysToRemove: [String] = []
for (key, pid) in tab.agentPIDs {
guard pid > 0 else {
keysToRemove.append(key)
continue
}
// kill(pid, 0) probes process liveness without sending a signal.
// ESRCH = process doesn't exist (stale). EPERM = process exists
// but we lack permission (not stale, keep tracking).
errno = 0
if kill(pid, 0) == -1, POSIXErrorCode(rawValue: errno) == .ESRCH {
keysToRemove.append(key)
}
}
if !keysToRemove.isEmpty {
for key in keysToRemove {
tab.statusEntries.removeValue(forKey: key)
tab.agentPIDs.removeValue(forKey: key)
}
// Also clear stale notifications (e.g. "Doing well, thanks!")
// left behind when Claude was killed without SessionEnd firing.
AppDelegate.shared?.notificationStore?.clearNotifications(forTabId: tab.id)
}
}
}
private func wireClosedBrowserTracking(for workspace: Workspace) {
@ -865,6 +926,20 @@ class TabManager: ObservableObject {
_ = panel.performBindingAction("start_search")
return
}
if let panel = selectedTerminalPanel {
let hadExistingSearch = panel.searchState != nil
let handled = startOrFocusTerminalSearch(panel.surface)
NSLog("Find: startSearch workspace=%@ panel=%@", panel.workspaceId.uuidString, panel.id.uuidString)
#if DEBUG
dlog(
"find.startSearch workspace=\(panel.workspaceId.uuidString.prefix(5)) " +
"panel=\(panel.id.uuidString.prefix(5)) existing=\(hadExistingSearch ? "yes" : "no") " +
"handled=\(handled ? 1 : 0) " +
"firstResponder=\(String(describing: panel.surface.hostedView.window?.firstResponder))"
)
#endif
return
}
focusedBrowserPanel?.startFind()
}
@ -1118,15 +1193,25 @@ class TabManager: ObservableObject {
workspace.clearPanelGitBranch(panelId: panelId)
}
if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
if let pullRequest = snapshot.pullRequest {
workspace.updatePanelPullRequest(
panelId: panelId,
number: pullRequest.number,
label: pullRequest.label,
url: pullRequest.url,
status: pullRequest.status
)
} else if previousBranch != nextBranch || (nextBranch == nil && workspace.panelPullRequests[panelId] != nil) {
workspace.clearPanelPullRequest(panelId: panelId)
}
#if DEBUG
let branchLabel = snapshot.branch ?? "none"
let prLabel = snapshot.pullRequest.map { "#\($0.number):\($0.status.rawValue)" } ?? "none"
dlog(
"workspace.gitProbe.apply workspace=\(workspaceId.uuidString.prefix(5)) " +
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0)"
"panel=\(panelId.uuidString.prefix(5)) branch=\(branchLabel) dirty=\(snapshot.isDirty ? 1 : 0) " +
"pr=\(prLabel)"
)
#endif
}
@ -1136,36 +1221,233 @@ class TabManager: ObservableObject {
) -> InitialWorkspaceGitMetadataSnapshot {
let branch = normalizedBranchName(runGitCommand(directory: directory, arguments: ["branch", "--show-current"]))
guard let branch else {
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false)
return InitialWorkspaceGitMetadataSnapshot(branch: nil, isDirty: false, pullRequest: nil)
}
let statusOutput = runGitCommand(directory: directory, arguments: ["status", "--porcelain", "-uno"])
let isDirty = !(statusOutput?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty)
let pullRequest = initialWorkspacePullRequestSnapshot(directory: directory, branch: branch)
return InitialWorkspaceGitMetadataSnapshot(branch: branch, isDirty: isDirty, pullRequest: pullRequest)
}
private nonisolated static func runGitCommand(directory: String, arguments: [String]) -> String? {
runCommand(
directory: directory,
executable: "git",
arguments: arguments
)
}
private nonisolated static func initialWorkspacePullRequestSnapshot(
directory: String,
branch: String
) -> SidebarPullRequestState? {
let repoSlug = githubRepositorySlug(directory: directory)
let repoArguments = repoSlug.map { ["--repo", $0] } ?? []
let result = runCommandResult(
directory: directory,
executable: "gh",
arguments: [
"pr", "view", branch,
] + repoArguments + [
"--json", "number,state,url",
"--jq", "[.number, .state, .url] | @tsv",
],
timeout: initialWorkspacePullRequestProbeTimeout
)
guard let result else { return nil }
guard let output = result.stdout,
result.exitStatus == 0,
!output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
#if DEBUG
let statusText: String
if result.timedOut {
statusText = "timeout"
} else if let exitStatus = result.exitStatus {
statusText = "exit=\(exitStatus)"
} else if let executionError = result.executionError {
statusText = "error=\(executionError)"
} else {
statusText = "unknown"
}
let stderr = debugLogSnippet(result.stderr) ?? "none"
dlog(
"workspace.gitProbe.pr.fail dir=\(directory) branch=\(branch) " +
"repo=\(repoSlug ?? "none") status=\(statusText) stderr=\(stderr)"
)
#endif
return nil
}
let trimmedOutput = output.trimmingCharacters(in: .whitespacesAndNewlines)
let fields = trimmedOutput
.trimmingCharacters(in: .whitespacesAndNewlines)
.split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false)
guard fields.count == 3,
let number = Int(fields[0]),
let url = URL(string: String(fields[2])) else {
#if DEBUG
dlog(
"workspace.gitProbe.pr.parseFail dir=\(directory) branch=\(branch) " +
"repo=\(repoSlug ?? "none") output=\(debugLogSnippet(trimmedOutput) ?? "none")"
)
#endif
return nil
}
let status: SidebarPullRequestStatus
switch fields[1].uppercased() {
case "OPEN":
status = .open
case "MERGED":
status = .merged
case "CLOSED":
status = .closed
default:
return nil
}
#if DEBUG
dlog(
"workspace.gitProbe.pr.success dir=\(directory) branch=\(branch) " +
"repo=\(repoSlug ?? "none") number=\(number) state=\(status.rawValue)"
)
#endif
return SidebarPullRequestState(number: number, label: "PR", url: url, status: status)
}
private nonisolated static func runCommand(
directory: String,
executable: String,
arguments: [String],
timeout: TimeInterval? = nil
) -> String? {
let result = runCommandResult(
directory: directory,
executable: executable,
arguments: arguments,
timeout: timeout
)
guard let result,
result.exitStatus == 0,
!result.timedOut else {
return nil
}
return result.stdout
}
private nonisolated static func runCommandResult(
directory: String,
executable: String,
arguments: [String],
timeout: TimeInterval? = nil
) -> CommandResult? {
let process = Process()
let stdout = Pipe()
let stderr = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["git", "-C", directory] + arguments
process.arguments = [executable] + arguments
process.currentDirectoryURL = URL(fileURLWithPath: directory)
process.standardOutput = stdout
process.standardError = FileHandle.nullDevice
process.standardError = stderr
let completion = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in
completion.signal()
}
do {
try process.run()
} catch {
return CommandResult(
stdout: nil,
stderr: nil,
exitStatus: nil,
timedOut: false,
executionError: String(describing: error)
)
}
if let timeout,
completion.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
if completion.wait(timeout: .now() + 0.2) == .timedOut {
kill(process.processIdentifier, SIGKILL)
_ = completion.wait(timeout: .now() + 0.2)
}
return CommandResult(
stdout: nil,
stderr: nil,
exitStatus: nil,
timedOut: true,
executionError: nil
)
} else if timeout == nil {
completion.wait()
}
let stdoutData = stdout.fileHandleForReading.readDataToEndOfFile()
let stderrData = stderr.fileHandleForReading.readDataToEndOfFile()
return CommandResult(
stdout: String(data: stdoutData, encoding: .utf8),
stderr: String(data: stderrData, encoding: .utf8),
exitStatus: process.terminationStatus,
timedOut: false,
executionError: nil
)
}
private nonisolated static func githubRepositorySlug(directory: String) -> String? {
guard let remoteURL = runGitCommand(
directory: directory,
arguments: ["remote", "get-url", "origin"]
) else {
return nil
}
// Drain stdout while the subprocess is active so large repos cannot fill the pipe buffer.
let data = stdout.fileHandleForReading.readDataToEndOfFile()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let trimmed = remoteURL.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let githubPrefixes = [
"git@github.com:",
"ssh://git@github.com/",
"https://github.com/",
"http://github.com/",
"git://github.com/",
]
for prefix in githubPrefixes where trimmed.hasPrefix(prefix) {
let path = String(trimmed.dropFirst(prefix.count))
return normalizedGitHubRepositorySlug(path)
}
guard let url = URL(string: trimmed),
let host = url.host?.lowercased(),
host == "github.com" else {
return nil
}
return String(data: data, encoding: .utf8)
return normalizedGitHubRepositorySlug(url.path)
}
private nonisolated static func normalizedGitHubRepositorySlug(_ rawPath: String) -> String? {
let trimmedPath = rawPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !trimmedPath.isEmpty else { return nil }
let components = trimmedPath.split(separator: "/").map(String.init)
guard components.count >= 2 else { return nil }
let owner = components[0]
var repo = components[1]
if repo.hasSuffix(".git") {
repo.removeLast(4)
}
guard !owner.isEmpty, !repo.isEmpty else { return nil }
return "\(owner)/\(repo)"
}
private nonisolated static func debugLogSnippet(_ value: String?) -> String? {
let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !trimmed.isEmpty else { return nil }
return String(trimmed.prefix(180))
}
private nonisolated static func normalizedBranchName(_ branch: String?) -> String? {
@ -1353,10 +1635,11 @@ class TabManager: ObservableObject {
guard let currentIndex = tabs.firstIndex(where: { $0.id == tabId }) else { return false }
if tabs.count <= 1 { return true }
let clamped = max(0, min(targetIndex, tabs.count - 1))
let workspace = tabs[currentIndex]
let clamped = clampedReorderIndex(for: workspace, targetIndex: targetIndex)
if currentIndex == clamped { return true }
let workspace = tabs.remove(at: currentIndex)
tabs.remove(at: currentIndex)
tabs.insert(workspace, at: clamped)
return true
}
@ -1412,6 +1695,15 @@ class TabManager: ObservableObject {
tabs.insert(tab, at: insertIndex)
}
private func clampedReorderIndex(for workspace: Workspace, targetIndex: Int) -> Int {
let clamped = max(0, min(targetIndex, tabs.count - 1))
let pinnedCount = tabs.filter { $0.isPinned }.count
if workspace.isPinned {
return min(clamped, max(0, pinnedCount - 1))
}
return max(clamped, pinnedCount)
}
// MARK: - Surface Directory Updates (Backwards Compatibility)
func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) {
@ -2686,6 +2978,7 @@ class TabManager: ObservableObject {
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil,
preferredProfileID: UUID? = nil,
focus: Bool = true
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
@ -2694,14 +2987,24 @@ class TabManager: ObservableObject {
orientation: orientation,
insertFirst: insertFirst,
url: url,
preferredProfileID: preferredProfileID,
focus: focus
)?.id
}
/// Create a new browser surface in a pane
func newBrowserSurface(tabId: UUID, inPane paneId: PaneID, url: URL? = nil) -> UUID? {
func newBrowserSurface(
tabId: UUID,
inPane paneId: PaneID,
url: URL? = nil,
preferredProfileID: UUID? = nil
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newBrowserSurface(inPane: paneId, url: url)?.id
return tab.newBrowserSurface(
inPane: paneId,
url: url,
preferredProfileID: preferredProfileID
)?.id
}
/// Get a browser panel by ID
@ -2716,6 +3019,7 @@ class TabManager: ObservableObject {
inWorkspace tabId: UUID,
url: URL? = nil,
preferSplitRight: Bool = false,
preferredProfileID: UUID? = nil,
insertAtEnd: Bool = false
) -> UUID? {
guard let workspace = tabs.first(where: { $0.id == tabId }) else { return nil }
@ -2729,7 +3033,8 @@ class TabManager: ObservableObject {
inPane: targetPaneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
insertAtEnd: insertAtEnd,
preferredProfileID: preferredProfileID
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
return browserPanel.id
@ -2755,6 +3060,7 @@ class TabManager: ObservableObject {
from: splitSourcePanelId,
orientation: .horizontal,
url: url,
preferredProfileID: preferredProfileID,
focus: true
) {
rememberFocusedSurface(tabId: tabId, surfaceId: browserPanel.id)
@ -2767,7 +3073,8 @@ class TabManager: ObservableObject {
inPane: paneId,
url: url,
focus: true,
insertAtEnd: insertAtEnd
insertAtEnd: insertAtEnd,
preferredProfileID: preferredProfileID
) else {
return nil
}
@ -2777,12 +3084,17 @@ class TabManager: ObservableObject {
/// Open a browser in the currently focused pane (as a new surface)
@discardableResult
func openBrowser(url: URL? = nil, insertAtEnd: Bool = false) -> UUID? {
func openBrowser(
url: URL? = nil,
preferredProfileID: UUID? = nil,
insertAtEnd: Bool = false
) -> UUID? {
guard let tabId = selectedTabId else { return nil }
return openBrowser(
inWorkspace: tabId,
url: url,
preferSplitRight: false,
preferredProfileID: preferredProfileID,
insertAtEnd: insertAtEnd
)
}
@ -2878,7 +3190,12 @@ class TabManager: ObservableObject {
in workspace: Workspace
) -> UUID? {
if let originalPane = workspace.bonsplitController.allPaneIds.first(where: { $0.id == snapshot.originalPaneId }),
let browserPanel = workspace.newBrowserSurface(inPane: originalPane, url: snapshot.url, focus: true) {
let browserPanel = workspace.newBrowserSurface(
inPane: originalPane,
url: snapshot.url,
focus: true,
preferredProfileID: snapshot.profileID
) {
let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count
let maxIndex = max(0, tabCount - 1)
let targetIndex = min(max(snapshot.originalTabIndex, 0), maxIndex)
@ -2895,7 +3212,8 @@ class TabManager: ObservableObject {
from: anchorPanelId,
orientation: orientation,
insertFirst: snapshot.fallbackSplitInsertFirst,
url: snapshot.url
url: snapshot.url,
preferredProfileID: snapshot.profileID
)?.id {
return browserPanelId
}
@ -2903,7 +3221,12 @@ class TabManager: ObservableObject {
guard let focusedPane = workspace.bonsplitController.focusedPaneId ?? workspace.bonsplitController.allPaneIds.first else {
return nil
}
return workspace.newBrowserSurface(inPane: focusedPane, url: snapshot.url, focus: true)?.id
return workspace.newBrowserSurface(
inPane: focusedPane,
url: snapshot.url,
focus: true,
preferredProfileID: snapshot.profileID
)?.id
}
/// Flash the currently focused panel so the user can visually confirm focus.
@ -4081,6 +4404,9 @@ extension TabManager {
for tab in tabs {
unwireClosedBrowserTracking(for: tab)
}
for workspaceId in Array(initialWorkspaceGitProbeGenerationByWorkspace.keys) {
clearInitialWorkspaceGitProbe(workspaceId: workspaceId)
}
// Clear non-@Published state without touching tabs/selectedTabId yet.
lastFocusedPanelByTab.removeAll()
@ -4137,6 +4463,21 @@ extension TabManager {
// never see an intermediate state with empty tabs or nil selection.
tabs = newTabs
selectedTabId = newSelectedId
for workspace in newTabs {
guard let terminalPanel = workspace.focusedTerminalPanel ?? workspace.panels.values
.compactMap({ $0 as? TerminalPanel })
.first,
let directory = normalizedWorkingDirectory(
workspace.panelDirectories[terminalPanel.id] ?? workspace.currentDirectory
) else {
continue
}
scheduleInitialWorkspaceGitMetadataRefresh(
workspaceId: workspace.id,
panelId: terminalPanel.id,
directory: directory
)
}
if let selectedTabId {
NotificationCenter.default.post(

View file

@ -60,6 +60,9 @@ class TerminalController {
private nonisolated static let socketProbePollTimeoutMs: Int32 = 100
private nonisolated static let socketProbePollAttempts = 3
private nonisolated static let socketProbePollRetryBackoffUs: useconds_t = 50_000
private nonisolated static let socketListenerFailureCaptureCooldown: TimeInterval = 60
private nonisolated static let socketListenerFailureCaptureLock = NSLock()
private nonisolated(unsafe) static var socketListenerFailureLastCapturedAt: [String: Date] = [:]
private nonisolated static let unixSocketPathMaxLength: Int = {
var addr = sockaddr_un()
// Reserve one byte for the null terminator.
@ -555,9 +558,35 @@ class TerminalController {
) {
let data = socketListenerEventData(stage: stage, errnoCode: errnoCode, extra: extra)
sentryBreadcrumb(message, category: "socket", data: data)
guard Self.shouldCaptureSocketListenerFailure(
message: message,
stage: stage,
path: data["path"] as? String ?? "",
errnoCode: errnoCode
) else {
return
}
sentryCaptureError(message, category: "socket", data: data, contextKey: "socket_listener")
}
private nonisolated static func shouldCaptureSocketListenerFailure(
message: String,
stage: String,
path: String,
errnoCode: Int32?
) -> Bool {
let key = "\(message)|\(stage)|\(path)|\(errnoCode.map(String.init) ?? "none")"
let now = Date()
socketListenerFailureCaptureLock.lock()
defer { socketListenerFailureCaptureLock.unlock() }
if let lastCapturedAt = socketListenerFailureLastCapturedAt[key],
now.timeIntervalSince(lastCapturedAt) < socketListenerFailureCaptureCooldown {
return false
}
socketListenerFailureLastCapturedAt[key] = now
return true
}
nonisolated static func acceptErrorClassification(errnoCode: Int32) -> String {
switch errnoCode {
case EINTR, ECONNABORTED, EAGAIN, EWOULDBLOCK:
@ -1572,6 +1601,12 @@ class TerminalController {
case "clear_status":
return clearStatus(args)
case "set_agent_pid":
return setAgentPID(args)
case "clear_agent_pid":
return clearAgentPID(args)
case "clear_meta":
return clearMeta(args)
@ -13394,6 +13429,61 @@ class TerminalController {
return trimmed.isEmpty ? nil : trimmed
}
private func schedulePanelMetadataMutation(
args: String,
options: [String: String],
missingPanelUsage: String,
mutation: @escaping (Tab, UUID) -> Void
) -> String {
let rawPanelArg = options["panel"] ?? options["surface"]
let surfaceIdFromOptions: UUID?
if let rawPanelArg {
if rawPanelArg.isEmpty {
return "ERROR: Missing panel id — usage: \(missingPanelUsage)"
}
guard let surfaceId = UUID(uuidString: rawPanelArg) else {
return "ERROR: Invalid panel id '\(rawPanelArg)'"
}
surfaceIdFromOptions = surfaceId
} else {
surfaceIdFromOptions = nil
}
if let tabArg = options["tab"]?.trimmingCharacters(in: .whitespacesAndNewlines),
!tabArg.isEmpty,
UUID(uuidString: tabArg) == nil,
Int(tabArg) == nil {
return "ERROR: Tab not found"
}
if let scope = Self.explicitSocketScope(options: options) {
DispatchQueue.main.async { [weak self] in
guard let self,
let tab = self.tabForSidebarMutation(id: scope.workspaceId) else {
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard validSurfaceIds.contains(scope.panelId) else { return }
mutation(tab, scope.panelId)
}
return "OK"
}
DispatchQueue.main.async { [weak self] in
guard let self,
let tab = self.resolveTabForReport(args) else {
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
guard let surfaceId = surfaceIdFromOptions ?? tab.focusedPanelId else { return }
guard validSurfaceIds.contains(surfaceId) else { return }
mutation(tab, surfaceId)
}
return "OK"
}
private func upsertSidebarMetadata(_ args: String, missingError: String) -> String {
guard tabManager != nil else { return "ERROR: TabManager not available" }
let parsed = parseOptionsNoStop(args)
@ -13436,6 +13526,14 @@ class TerminalController {
return tabResolution.error ?? "ERROR: No tab selected"
}
let pidValue: pid_t? = {
if let rawPid = normalizedOptionValue(parsed.options["pid"]),
let p = Int32(rawPid), p > 0 {
return p
}
return nil
}()
DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
guard Self.shouldReplaceStatusEntry(
@ -13448,6 +13546,10 @@ class TerminalController {
priority: priority,
format: format
) else {
// Still update PID tracking even if the status display hasn't changed.
if let pidValue {
tab.agentPIDs[key] = pidValue
}
return
}
tab.statusEntries[key] = SidebarStatusEntry(
@ -13460,6 +13562,9 @@ class TerminalController {
format: format,
timestamp: Date()
)
if let pidValue {
tab.agentPIDs[key] = pidValue
}
}
return "OK"
}
@ -13479,10 +13584,50 @@ class TerminalController {
if tab.statusEntries.removeValue(forKey: key) == nil {
result = "OK (key not found)"
}
tab.agentPIDs.removeValue(forKey: key)
}
return result
}
/// Register an agent PID for stale-session detection without setting a visible status entry.
/// Usage: set_agent_pid <key> <pid> [--tab=<id>]
private func setAgentPID(_ args: String) -> String {
let parsed = parseOptions(args)
guard parsed.positional.count >= 2,
let pid = Int32(parsed.positional[1]), pid > 0 else {
return "ERROR: Usage: set_agent_pid <key> <pid> [--tab=<id>]"
}
let key = parsed.positional[0]
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
guard let targetTabId = tabResolution.tabId else {
return tabResolution.error ?? "ERROR: No tab selected"
}
DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
tab.agentPIDs[key] = pid
}
return "OK"
}
/// Unregister an agent PID. Usage: clear_agent_pid <key> [--tab=<id>]
private func clearAgentPID(_ args: String) -> String {
let parsed = parseOptions(args)
guard let key = parsed.positional.first else {
return "ERROR: Usage: clear_agent_pid <key> [--tab=<id>]"
}
// Resolve tab ID synchronously before dispatching to avoid
// racing against selection changes on the main queue.
let tabResolution = resolveTabIdForSidebarMutation(reportArgs: args, options: parsed.options)
guard let targetTabId = tabResolution.tabId else {
return tabResolution.error ?? "ERROR: No tab selected"
}
DispatchQueue.main.async { [weak self] in
guard let self, let tab = self.tabForSidebarMutation(id: targetTabId) else { return }
tab.agentPIDs.removeValue(forKey: key)
}
return "OK"
}
private func sidebarMetadataLine(_ entry: SidebarStatusEntry) -> String {
var line = "\(entry.key)=\(entry.value)"
if let icon = entry.icon { line += " icon=\(icon)" }
@ -13863,40 +14008,13 @@ class TerminalController {
}
let label = String(labelRaw.prefix(16))
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
// Shell integration provides explicit workspace/panel UUIDs for browser metadata.
// Keep this telemetry path off-main so SwiftUI render passes can't deadlock the socket handler.
return schedulePanelMetadataMutation(
args: args,
options: parsed.options,
missingPanelUsage: "report_pr <number> <url> [--label=PR] [--state=open|merged|closed] [--tab=X] [--panel=Y]"
) { tab, surfaceId in
guard Self.shouldReplacePullRequest(
current: tab.panelPullRequests[surfaceId],
number: number,
@ -13915,48 +14033,17 @@ class TerminalController {
status: status
)
}
return result
}
private func clearPullRequest(_ args: String) -> String {
let parsed = parseOptions(args)
var result = "OK"
DispatchQueue.main.sync {
guard let tab = resolveTabForReport(args) else {
result = parsed.options["tab"] != nil ? "ERROR: Tab not found" : "ERROR: No tab selected"
return
}
let validSurfaceIds = Set(tab.panels.keys)
tab.pruneSurfaceMetadata(validSurfaceIds: validSurfaceIds)
let panelArg = parsed.options["panel"] ?? parsed.options["surface"]
let surfaceId: UUID
if let panelArg {
if panelArg.isEmpty {
result = "ERROR: Missing panel id — usage: clear_pr [--tab=X] [--panel=Y]"
return
}
guard let parsedId = UUID(uuidString: panelArg) else {
result = "ERROR: Invalid panel id '\(panelArg)'"
return
}
surfaceId = parsedId
} else {
guard let focused = tab.focusedPanelId else {
result = "ERROR: Missing panel id (no focused surface)"
return
}
surfaceId = focused
}
guard validSurfaceIds.contains(surfaceId) else {
result = "ERROR: Panel not found '\(surfaceId.uuidString)'"
return
}
return schedulePanelMetadataMutation(
args: args,
options: parsed.options,
missingPanelUsage: "clear_pr [--tab=X] [--panel=Y]"
) { tab, surfaceId in
tab.clearPanelPullRequest(panelId: surfaceId)
}
return result
}
private func reportPorts(_ args: String) -> String {

View file

@ -27,10 +27,10 @@ class UpdateController {
}
init() {
// Default to manual update checks. This also prevents Sparkle from prompting at startup.
// cmux checks for updates in the background, but keeps automatic download and
// profile submission disabled so all install intent stays user-driven.
let defaults = UserDefaults.standard
defaults.register(defaults: [
"SUEnableAutomaticChecks": false,
"SUSendProfileInfo": false,
"SUAutomaticallyUpdate": false,
])
@ -59,8 +59,8 @@ class UpdateController {
guard !didStartUpdater else { return }
ensureSparkleInstallationCache()
#if DEBUG
// UI tests need to exercise Sparkle's permission request deterministically.
// Clearing these defaults causes Sparkle to re-request permission on next start.
// Keep the permission-related defaults resettable for UI tests even though the
// delegate now suppresses Sparkle's permission UI entirely.
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_RESET_SPARKLE_PERMISSION"] == "1" {
let defaults = UserDefaults.standard
defaults.removeObject(forKey: "SUEnableAutomaticChecks")
@ -71,13 +71,9 @@ class UpdateController {
}
#endif
do {
// cmux never enables automatic update checks; we rely on the in-app update pill.
// Sparkle reads these from defaults, but set them explicitly before starting.
let defaults = UserDefaults.standard
defaults.set(false, forKey: "SUEnableAutomaticChecks")
defaults.set(false, forKey: "SUSendProfileInfo")
defaults.set(false, forKey: "SUAutomaticallyUpdate")
updater.automaticallyChecksForUpdates = true
updater.automaticallyDownloadsUpdates = false
updater.sendsSystemProfile = false
try updater.start()
didStartUpdater = true
} catch {
@ -201,7 +197,7 @@ class UpdateController {
/// Validate the check for updates menu item.
func validateMenuItem(_ item: NSMenuItem) -> Bool {
if item.action == #selector(checkForUpdates) {
// Always allow user-initiated checks; we start Sparkle lazily on first use.
// Always allow user-initiated checks; Sparkle can safely surface current progress.
return true
}
return true

View file

@ -13,6 +13,10 @@ enum UpdateFeedResolver {
}
extension UpdateDriver: SPUUpdaterDelegate {
func updaterShouldPromptForPermissionToCheck(forUpdates _: SPUUpdater) -> Bool {
false
}
func feedURLString(for updater: SPUUpdater) -> String? {
#if DEBUG
let env = ProcessInfo.processInfo.environment
@ -35,6 +39,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
/// Called when an update is scheduled to install silently,
/// which occurs when automatic download is enabled.
func updater(_ updater: SPUUpdater, willInstallUpdateOnQuit item: SUAppcastItem, immediateInstallationBlock immediateInstallHandler: @escaping () -> Void) -> Bool {
viewModel.clearDetectedUpdate()
viewModel.state = .installing(.init(
isAutoUpdate: true,
retryTerminatingApplication: immediateInstallHandler,
@ -56,6 +61,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) {
viewModel.recordDetectedUpdate(item)
let version = item.displayVersionString
let fileURL = item.fileURL?.absoluteString ?? ""
if fileURL.isEmpty {
@ -66,6 +72,7 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) {
viewModel.clearDetectedUpdate()
let nsError = error as NSError
let reasonValue = (nsError.userInfo[SPUNoUpdateFoundReasonKey] as? NSNumber)?.intValue
let reason = reasonValue.map { SPUNoUpdateFoundReason(rawValue: OSStatus($0)) } ?? nil
@ -80,13 +87,18 @@ extension UpdateDriver: SPUUpdaterDelegate {
}
}
@MainActor
func updater(_ updater: SPUUpdater, userDidMake _: SPUUserUpdateChoice, forUpdate _: SUAppcastItem, state _: SPUUserUpdateState) {
viewModel.clearDetectedUpdate()
}
func updaterWillRelaunchApplication(_ updater: SPUUpdater) {
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
Task { @MainActor in
AppDelegate.shared?.persistSessionForUpdateRelaunch()
TerminalController.shared.stop()
NSApp.invalidateRestorableState()
for window in NSApp.windows {
window.invalidateRestorableState()
}
}
}
}

View file

@ -6,6 +6,14 @@ enum UpdateTestSupport {
static func applyIfNeeded(to viewModel: UpdateViewModel) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_MODE"] == "1" else { return }
if let detectedVersion = env["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"],
!detectedVersion.isEmpty {
DispatchQueue.main.async {
viewModel.detectedUpdateVersion = UpdateViewModel.normalizedDetectedUpdateVersion(from: detectedVersion)
}
}
guard let state = env["CMUX_UI_TEST_UPDATE_STATE"] else { return }
DispatchQueue.main.async {

View file

@ -6,6 +6,7 @@ import Sparkle
class UpdateViewModel: ObservableObject {
@Published var state: UpdateState = .idle
@Published var overrideState: UpdateState?
@Published var detectedUpdateVersion: String?
#if DEBUG
@Published var debugOverrideText: String?
#endif
@ -14,6 +15,14 @@ class UpdateViewModel: ObservableObject {
overrideState ?? state
}
func recordDetectedUpdate(_ item: SUAppcastItem) {
detectedUpdateVersion = Self.normalizedDetectedUpdateVersion(from: item.displayVersionString)
}
func clearDetectedUpdate() {
detectedUpdateVersion = nil
}
var text: String {
#if DEBUG
if let debugOverrideText { return debugOverrideText }
@ -334,6 +343,11 @@ class UpdateViewModel: ObservableObject {
return nil
}
}
static func normalizedDetectedUpdateVersion(from version: String) -> String? {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
enum UpdateState: Equatable {

View file

@ -240,20 +240,11 @@ extension Workspace {
setCustomColor(snapshot.customColor)
isPinned = snapshot.isPinned
statusEntries = Dictionary(
uniqueKeysWithValues: snapshot.statusEntries.map { entry in
(
entry.key,
SidebarStatusEntry(
key: entry.key,
value: entry.value,
icon: entry.icon,
color: entry.color,
timestamp: Date(timeIntervalSince1970: entry.timestamp)
)
)
}
)
// Status entries and agent PIDs are ephemeral runtime state tied to running
// processes (e.g. claude_code "Running"). Don't restore them across app
// restarts because the processes that set them are gone.
statusEntries.removeAll()
agentPIDs.removeAll()
logEntries = snapshot.logEntries.map { entry in
SidebarLogEntry(
message: entry.message,
@ -382,6 +373,7 @@ extension Workspace {
let historySnapshot = browserPanel.sessionNavigationHistorySnapshot()
browserSnapshot = SessionBrowserPanelSnapshot(
urlString: browserPanel.preferredURLStringForOmnibar(),
profileID: browserPanel.profileID,
shouldRenderWebView: browserPanel.shouldRenderWebView,
pageZoom: Double(browserPanel.currentPageZoomFactor()),
developerToolsVisible: browserPanel.isDeveloperToolsVisible(),
@ -567,7 +559,8 @@ extension Workspace {
guard let browserPanel = newBrowserSurface(
inPane: paneId,
url: initialURL,
focus: false
focus: false,
preferredProfileID: snapshot.browser?.profileID
) else {
return nil
}
@ -4517,6 +4510,7 @@ enum SidebarBranchOrdering {
struct ClosedBrowserPanelRestoreSnapshot {
let workspaceId: UUID
let url: URL?
let profileID: UUID?
let originalPaneId: UUID
let originalTabIndex: Int
let fallbackSplitOrientation: SplitOrientation?
@ -4534,6 +4528,7 @@ final class Workspace: Identifiable, ObservableObject {
@Published var isPinned: Bool = false
@Published var customColor: String? // hex string, e.g. "#C0392B"
@Published var currentDirectory: String
private(set) var preferredBrowserProfileID: UUID?
/// Ordinal for CMUX_PORT range assignment (monotonically increasing per app session)
var portOrdinal: Int = 0
@ -4641,6 +4636,9 @@ final class Workspace: Identifiable, ObservableObject {
return formatter
}()
private var panelShellActivityStates: [UUID: PanelShellActivityState] = [:]
/// PIDs associated with agent status entries (e.g. claude_code), keyed by status key.
/// Used for stale-session detection: if the PID is dead, the status entry is cleared.
var agentPIDs: [String: pid_t] = [:]
private var restoredTerminalScrollbackByPanelId: [UUID: String] = [:]
private static func isProxyOnlyRemoteError(_ detail: String) -> Bool {
@ -5014,6 +5012,35 @@ final class Workspace: Identifiable, ObservableObject {
)
}
panelSubscriptions[browserPanel.id] = subscription
setPreferredBrowserProfileID(browserPanel.profileID)
}
func setPreferredBrowserProfileID(_ profileID: UUID?) {
guard let profileID else {
preferredBrowserProfileID = nil
return
}
guard BrowserProfileStore.shared.profileDefinition(id: profileID) != nil else { return }
preferredBrowserProfileID = profileID
}
private func resolvedNewBrowserProfileID(
preferredProfileID: UUID? = nil,
sourcePanelId: UUID? = nil
) -> UUID {
if let preferredProfileID,
BrowserProfileStore.shared.profileDefinition(id: preferredProfileID) != nil {
return preferredProfileID
}
if let sourcePanelId,
let sourceBrowserPanel = browserPanel(for: sourcePanelId) {
return sourceBrowserPanel.profileID
}
if let preferredBrowserProfileID,
BrowserProfileStore.shared.profileDefinition(id: preferredBrowserProfileID) != nil {
return preferredBrowserProfileID
}
return BrowserProfileStore.shared.effectiveLastUsedProfileID
}
private func installMarkdownPanelSubscription(_ markdownPanel: MarkdownPanel) {
@ -5410,6 +5437,7 @@ final class Workspace: Identifiable, ObservableObject {
func resetSidebarContext(reason: String = "unspecified") {
statusEntries.removeAll()
agentPIDs.removeAll()
logEntries.removeAll()
progress = nil
gitBranch = nil
@ -6301,6 +6329,7 @@ final class Workspace: Identifiable, ObservableObject {
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil,
preferredProfileID: UUID? = nil,
focus: Bool = true
) -> BrowserPanel? {
// Find the pane containing the source panel
@ -6319,6 +6348,10 @@ final class Workspace: Identifiable, ObservableObject {
// Create browser panel
let browserPanel = BrowserPanel(
workspaceId: id,
profileID: resolvedNewBrowserProfileID(
preferredProfileID: preferredProfileID,
sourcePanelId: panelId
),
initialURL: url,
proxyEndpoint: remoteProxyEndpoint,
isRemoteWorkspace: isRemoteWorkspace,
@ -6326,6 +6359,7 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
setPreferredBrowserProfileID(browserPanel.profileID)
// Pre-generate the bonsplit tab ID so the mapping exists before the split lands.
let newTab = Bonsplit.Tab(
@ -6382,6 +6416,7 @@ final class Workspace: Identifiable, ObservableObject {
url: URL? = nil,
focus: Bool? = nil,
insertAtEnd: Bool = false,
preferredProfileID: UUID? = nil,
bypassInsecureHTTPHostOnce: String? = nil
) -> BrowserPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
@ -6390,6 +6425,7 @@ final class Workspace: Identifiable, ObservableObject {
let browserPanel = BrowserPanel(
workspaceId: id,
profileID: resolvedNewBrowserProfileID(preferredProfileID: preferredProfileID),
initialURL: url,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce,
proxyEndpoint: remoteProxyEndpoint,
@ -6398,6 +6434,7 @@ final class Workspace: Identifiable, ObservableObject {
)
panels[browserPanel.id] = browserPanel
panelTitles[browserPanel.id] = browserPanel.displayTitle
setPreferredBrowserProfileID(browserPanel.profileID)
guard let newTabId = bonsplitController.createTab(
title: browserPanel.displayTitle,
@ -6827,6 +6864,7 @@ final class Workspace: Identifiable, ObservableObject {
pendingClosedBrowserRestoreSnapshots[tab.id] = ClosedBrowserPanelRestoreSnapshot(
workspaceId: id,
url: resolvedURL,
profileID: browserPanel.profileID,
originalPaneId: pane.id,
originalTabIndex: tabIndex,
fallbackSplitOrientation: fallbackPlan?.orientation,
@ -8010,14 +8048,27 @@ final class Workspace: Identifiable, ObservableObject {
private func createBrowserToRight(of anchorTabId: TabID, inPane paneId: PaneID, url: URL? = nil) {
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
guard let newPanel = newBrowserSurface(inPane: paneId, url: url, focus: true) else { return }
let preferredProfileID = panelIdFromSurfaceId(anchorTabId).flatMap { browserPanel(for: $0)?.profileID }
guard let newPanel = newBrowserSurface(
inPane: paneId,
url: url,
focus: true,
preferredProfileID: preferredProfileID
) else { return }
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
}
private func duplicateBrowserToRight(anchorTabId: TabID, inPane paneId: PaneID) {
guard let panelId = panelIdFromSurfaceId(anchorTabId),
let browser = browserPanel(for: panelId) else { return }
createBrowserToRight(of: anchorTabId, inPane: paneId, url: browser.currentURL)
let targetIndex = insertionIndexToRight(of: anchorTabId, inPane: paneId)
guard let newPanel = newBrowserSurface(
inPane: paneId,
url: browser.currentURL,
focus: true,
preferredProfileID: browser.profileID
) else { return }
_ = reorderSurface(panelId: newPanel.id, toIndex: targetIndex)
}
private func promptRenamePanel(tabId: TabID) {

View file

@ -587,6 +587,10 @@ struct cmuxApp: App {
BrowserHistoryStore.shared.clearHistory()
}
Button(String(localized: "menu.view.importFromBrowser", defaultValue: "Import From Browser…")) {
BrowserDataImportCoordinator.shared.presentImportDialog()
}
splitCommandButton(title: String(localized: "menu.view.nextWorkspace", defaultValue: "Next Workspace"), shortcut: nextWorkspaceMenuShortcut) {
activeTabManager.selectNextTab()
}
@ -645,7 +649,6 @@ struct cmuxApp: App {
private func showAboutPanel() {
AboutWindowController.shared.show()
NSApp.activate(ignoringOtherApps: true)
}
private func applyAppearance() {
@ -1033,8 +1036,8 @@ struct cmuxApp: App {
}
private func closePanelOrWindow() {
if let window = NSApp.keyWindow,
window.identifier?.rawValue == "cmux.settings" {
if let window = NSApp.keyWindow ?? NSApp.mainWindow,
cmuxWindowShouldOwnCloseShortcut(window) {
window.performClose(nil)
return
}
@ -1061,6 +1064,26 @@ struct cmuxApp: App {
}
}
private let cmuxAuxiliaryWindowIdentifiers: Set<String> = [
"cmux.settings",
"cmux.about",
"cmux.licenses",
"cmux.browser-popup",
"cmux.settingsAboutTitlebarDebug",
"cmux.debugWindowControls",
"cmux.sidebarDebug",
"cmux.menubarDebug",
"cmux.backgroundDebug",
]
/// Returns whether the given window should handle the standard close shortcut
/// as a standalone auxiliary window instead of routing it through workspace or
/// panel-close behavior.
func cmuxWindowShouldOwnCloseShortcut(_ window: NSWindow?) -> Bool {
guard let identifier = window?.identifier?.rawValue else { return false }
return cmuxAuxiliaryWindowIdentifiers.contains(identifier)
}
private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable {
case settings
case about
@ -1526,6 +1549,8 @@ private enum DebugWindowConfigSnapshot {
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0)))
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)"))
sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)"))
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
sidebarBranchVerticalLayout=\(boolValue(defaults, key: SidebarBranchLayoutSettings.key, fallback: SidebarBranchLayoutSettings.defaultVerticalLayout))
@ -2153,8 +2178,10 @@ private struct AboutPanelView: View {
private struct SidebarDebugView: View {
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@ -2308,7 +2335,9 @@ private struct SidebarDebugView: View {
HStack(spacing: 12) {
Button("Reset Tint") {
sidebarTintOpacity = 0.62
sidebarTintHex = "#000000"
sidebarTintHex = SidebarTintDefaults.hex
sidebarTintHexLight = nil
sidebarTintHexDark = nil
}
Button("Reset Blur") {
sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue
@ -2389,6 +2418,8 @@ private struct SidebarDebugView: View {
sidebarState=\(sidebarState)
sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity))
sidebarTintHex=\(sidebarTintHex)
sidebarTintHexLight=\(sidebarTintHexLight ?? "(nil)")
sidebarTintHexDark=\(sidebarTintHexDark ?? "(nil)")
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
sidebarBranchVerticalLayout=\(sidebarBranchVerticalLayout)
@ -2416,6 +2447,8 @@ private struct SidebarDebugView: View {
sidebarTintOpacity = preset.tintOpacity
sidebarCornerRadius = preset.cornerRadius
sidebarBlurOpacity = preset.blurOpacity
sidebarTintHexLight = nil
sidebarTintHexDark = nil
}
}
@ -3006,6 +3039,18 @@ enum CommandPaletteRenameSelectionSettings {
}
}
enum CommandPaletteSwitcherSearchSettings {
static let searchAllSurfacesKey = "commandPalette.switcherSearchAllSurfaces"
static let defaultSearchAllSurfaces = false
static func searchAllSurfacesEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: searchAllSurfacesKey) == nil {
return defaultSearchAllSurfaces
}
return defaults.bool(forKey: searchAllSurfacesKey)
}
}
enum ClaudeCodeIntegrationSettings {
static let hooksEnabledKey = "claudeCodeHooksEnabled"
static let defaultHooksEnabled = true
@ -3073,6 +3118,8 @@ struct SettingsView: View {
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@AppStorage(CommandPaletteSwitcherSearchSettings.searchAllSurfacesKey)
private var commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@ -3095,6 +3142,10 @@ struct SettingsView: View {
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowMetadata = true
@AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults.hex
@AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String?
@AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String?
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults.opacity
@ObservedObject private var notificationStore = TerminalNotificationStore.shared
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@ -3104,6 +3155,7 @@ struct SettingsView: View {
@State private var showOpenAccessConfirmation = false
@State private var pendingOpenAccessMode: SocketControlMode?
@State private var browserHistoryEntryCount: Int = 0
@State private var detectedImportBrowsers: [InstalledBrowserCandidate] = []
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
@State private var socketPasswordDraft = ""
@State private var socketPasswordStatusMessage: String?
@ -3169,6 +3221,30 @@ struct SettingsView: View {
)
}
private var settingsSidebarTintLightBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: sidebarTintHexLight ?? sidebarTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
sidebarTintHexLight = nsColor.hexString()
}
)
}
private var settingsSidebarTintDarkBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: sidebarTintHexDark ?? sidebarTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
sidebarTintHexDark = nsColor.hexString()
}
)
}
private var hasSocketPasswordConfigured: Bool {
SocketControlPasswordStore.hasConfiguredPassword()
}
@ -3184,6 +3260,10 @@ struct SettingsView: View {
}
}
private var browserImportSubtitle: String {
InstalledBrowserDetector.summaryText(for: detectedImportBrowsers)
}
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
}
@ -3696,6 +3776,23 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces"),
subtitle: commandPaletteSearchAllSurfaces
? String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOn", defaultValue: "Cmd+P also matches terminal, browser, and markdown surfaces across workspaces.")
: String(localized: "settings.app.commandPaletteSearchAllSurfaces.subtitleOff", defaultValue: "Cmd+P matches workspace rows only.")
) {
Toggle("", isOn: $commandPaletteSearchAllSurfaces)
.labelsHidden()
.controlSize(.small)
.accessibilityIdentifier("CommandPaletteSearchAllSurfacesToggle")
.accessibilityLabel(
String(localized: "settings.app.commandPaletteSearchAllSurfaces", defaultValue: "Command Palette Searches All Surfaces")
)
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"),
subtitle: sidebarHideAllDetails
@ -3920,6 +4017,83 @@ struct SettingsView: View {
}
}
SettingsSectionHeader(title: String(localized: "settings.section.sidebarAppearance", defaultValue: "Sidebar Appearance"))
SettingsCard {
SettingsCardRow(
String(localized: "settings.sidebarAppearance.tintColorLight", defaultValue: "Light Mode Tint"),
subtitle: String(localized: "settings.sidebarAppearance.tintColorLight.subtitle", defaultValue: "Sidebar tint color when using light appearance.")
) {
HStack(spacing: 8) {
ColorPicker(
String(localized: "settings.sidebarAppearance.tintColorLight.picker", defaultValue: "Light tint"),
selection: settingsSidebarTintLightBinding,
supportsOpacity: false
)
.labelsHidden()
.frame(width: 38)
Text(sidebarTintHexLight ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 76, alignment: .trailing)
}
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.sidebarAppearance.tintColorDark", defaultValue: "Dark Mode Tint"),
subtitle: String(localized: "settings.sidebarAppearance.tintColorDark.subtitle", defaultValue: "Sidebar tint color when using dark appearance.")
) {
HStack(spacing: 8) {
ColorPicker(
String(localized: "settings.sidebarAppearance.tintColorDark.picker", defaultValue: "Dark tint"),
selection: settingsSidebarTintDarkBinding,
supportsOpacity: false
)
.labelsHidden()
.frame(width: 38)
Text(sidebarTintHexDark ?? String(localized: "settings.sidebarAppearance.defaultLabel", defaultValue: "Default"))
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 76, alignment: .trailing)
}
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.sidebarAppearance.tintOpacity", defaultValue: "Tint Opacity"),
subtitle: String(localized: "settings.sidebarAppearance.tintOpacity.subtitle", defaultValue: "How strongly the tint color shows over the sidebar material.")
) {
HStack(spacing: 8) {
Slider(value: $sidebarTintOpacity, in: 0...1)
.frame(width: 140)
Text(String(format: "%.0f%%", sidebarTintOpacity * 100))
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.frame(width: 36, alignment: .trailing)
}
}
SettingsCardDivider()
SettingsCardRow(
String(localized: "settings.sidebarAppearance.reset", defaultValue: "Reset Sidebar Tint"),
subtitle: String(localized: "settings.sidebarAppearance.reset.subtitle", defaultValue: "Restore default sidebar appearance.")
) {
Button(String(localized: "settings.sidebarAppearance.reset.button", defaultValue: "Reset")) {
sidebarTintHexLight = nil
sidebarTintHexDark = nil
sidebarTintHex = SidebarTintDefaults.hex
sidebarTintOpacity = SidebarTintDefaults.opacity
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
SettingsSectionHeader(title: String(localized: "settings.section.automation", defaultValue: "Automation"))
SettingsCard {
SettingsPickerRow(
@ -4196,6 +4370,25 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(String(localized: "settings.browser.import", defaultValue: "Import From Browser"), subtitle: browserImportSubtitle) {
HStack(spacing: 8) {
Button(String(localized: "settings.browser.import.choose", defaultValue: "Choose…")) {
BrowserDataImportCoordinator.shared.presentImportDialog()
refreshDetectedImportBrowsers()
}
.buttonStyle(.bordered)
.controlSize(.small)
Button(String(localized: "settings.browser.import.refresh", defaultValue: "Refresh")) {
refreshDetectedImportBrowsers()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
SettingsCardDivider()
SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) {
Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) {
showClearBrowserHistoryConfirmation = true
@ -4338,6 +4531,7 @@ struct SettingsView: View {
browserThemeMode = BrowserThemeSettings.mode(defaults: .standard).rawValue
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
refreshDetectedImportBrowsers()
reloadWorkspaceTabColorSettings()
refreshNotificationCustomSoundStatus()
}
@ -4467,6 +4661,7 @@ struct SettingsView: View {
showMenuBarExtra = MenuBarExtraSettings.defaultShowInMenuBar
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
commandPaletteSearchAllSurfaces = CommandPaletteSwitcherSearchSettings.defaultSearchAllSurfaces
ShortcutHintDebugSettings.resetVisibilityDefaults()
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@ -4484,11 +4679,16 @@ struct SettingsView: View {
sidebarShowLog = true
sidebarShowProgress = true
sidebarShowMetadata = true
sidebarTintHex = SidebarTintDefaults.hex
sidebarTintHexLight = nil
sidebarTintHexDark = nil
sidebarTintOpacity = SidebarTintDefaults.opacity
showOpenAccessConfirmation = false
pendingOpenAccessMode = nil
socketPasswordDraft = ""
socketPasswordStatusMessage = nil
socketPasswordStatusIsError = false
refreshDetectedImportBrowsers()
KeyboardShortcutSettings.resetAll()
WorkspaceTabColorSettings.reset()
reloadWorkspaceTabColorSettings()
@ -4534,6 +4734,10 @@ struct SettingsView: View {
private func saveBrowserInsecureHTTPAllowlist() {
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
}
private func refreshDetectedImportBrowsers() {
detectedImportBrowsers = InstalledBrowserDetector.detectInstalledBrowsers()
}
}
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {

View file

@ -571,6 +571,65 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
)
}
func testCmdWClosesAuxiliaryWindowInsteadOfMainTerminalPanel() throws {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
XCTAssertNotNil(window(withId: windowId), "Expected test window")
guard let manager = appDelegate.tabManagerFor(windowId: windowId) else {
XCTFail("Expected test manager")
return
}
let mainWorkspaceCount = manager.tabs.count
let auxiliaryWindow = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable, .miniaturizable],
backing: .buffered,
defer: false
)
auxiliaryWindow.isReleasedWhenClosed = false
auxiliaryWindow.identifier = NSUserInterfaceItemIdentifier("cmux.about")
auxiliaryWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
defer {
if auxiliaryWindow.isVisible {
auxiliaryWindow.performClose(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
}
}
guard let event = makeKeyDownEvent(
key: "w",
modifiers: [.command],
keyCode: 13,
windowNumber: auxiliaryWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+W event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
throw XCTSkip("debugHandleCustomShortcut is only available in DEBUG builds")
#endif
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertFalse(auxiliaryWindow.isVisible, "Cmd+W should close the auxiliary window")
XCTAssertNotNil(self.window(withId: windowId), "Cmd+W in auxiliary window should not close the main window")
XCTAssertEqual(manager.tabs.count, mainWorkspaceCount, "Cmd+W in auxiliary window should not close a terminal panel")
XCTAssertNotEqual(NSApp.keyWindow?.identifier?.rawValue, "cmux.about", "Closed auxiliary window should not remain key")
}
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -955,6 +1014,63 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
#endif
}
func testCmdShiftPRequestsCommandPaletteCommands() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
let paletteExpectation = expectation(description: "Expected command palette commands request for Cmd+Shift+P")
var observedPaletteWindow: NSWindow?
let paletteToken = NotificationCenter.default.addObserver(
forName: .commandPaletteRequested,
object: nil,
queue: nil
) { notification in
observedPaletteWindow = notification.object as? NSWindow
paletteExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(paletteToken) }
let switcherExpectation = expectation(description: "Cmd+Shift+P should not request command palette switcher")
switcherExpectation.isInverted = true
let switcherToken = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { _ in
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(switcherToken) }
guard let event = makeKeyDownEvent(
key: "P",
modifiers: [.command, .shift],
keyCode: 35,
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct Cmd+Shift+P event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
wait(for: [paletteExpectation, switcherExpectation], timeout: 1.0)
XCTAssertEqual(observedPaletteWindow?.windowNumber, window.windowNumber)
}
func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -1712,6 +1828,77 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(observedDismissWindow?.windowNumber, window.windowNumber)
}
func testArrowNavigationRoutesWhileCommandPaletteOverlayIsInteractiveBeforeVisibilitySync() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: windowId)
}
guard let window = window(withId: windowId),
let contentView = window.contentView else {
XCTFail("Expected test window")
return
}
let overlayContainer = NSView(frame: contentView.bounds)
overlayContainer.identifier = commandPaletteOverlayContainerIdentifier
overlayContainer.alphaValue = 1
overlayContainer.isHidden = false
contentView.addSubview(overlayContainer)
let fieldEditor = CommandPaletteMarkedTextFieldEditor(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
fieldEditor.isFieldEditor = true
overlayContainer.addSubview(fieldEditor)
XCTAssertTrue(window.makeFirstResponder(fieldEditor))
appDelegate.setCommandPaletteVisible(false, for: window)
defer {
overlayContainer.removeFromSuperview()
fieldEditor.removeFromSuperview()
}
let moveExpectation = expectation(
description: "Expected command palette move-selection notification while overlay is interactive"
)
var observedDelta: Int?
var observedWindow: NSWindow?
let moveToken = NotificationCenter.default.addObserver(
forName: .commandPaletteMoveSelection,
object: nil,
queue: nil
) { notification in
observedWindow = notification.object as? NSWindow
observedDelta = notification.userInfo?["delta"] as? Int
moveExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(moveToken) }
guard let downArrowEvent = makeKeyDownEvent(
key: String(UnicodeScalar(NSDownArrowFunctionKey)!),
modifiers: [],
keyCode: 125,
windowNumber: window.windowNumber
) else {
XCTFail("Failed to construct Down Arrow event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: downArrowEvent))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
wait(for: [moveExpectation], timeout: 1.0)
XCTAssertEqual(observedWindow?.windowNumber, window.windowNumber)
XCTAssertEqual(observedDelta, 1)
}
func testEscapeDismissesCommandPaletteWhenVisibilityStateStaysStalePastInitialPendingWindow() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")

View file

@ -0,0 +1,232 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class BrowserImportMappingTests: XCTestCase {
@MainActor
func testDefaultExecutionPlanUsesSeparateModeForMultipleSourceProfiles() {
let defaultProfile = BrowserProfileDefinition(
id: UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!,
displayName: "Default",
createdAt: .distantPast,
isBuiltInDefault: true
)
let sourceProfiles = [
makeSourceProfile(displayName: "You", path: "/tmp/browser-import-you", isDefault: true),
makeSourceProfile(displayName: "austin", path: "/tmp/browser-import-austin", isDefault: false),
]
let plan = BrowserImportPlanResolver.defaultPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: [defaultProfile],
preferredSingleDestinationProfileID: defaultProfile.id
)
XCTAssertEqual(plan.mode, .separateProfiles)
XCTAssertEqual(plan.entries.count, 2)
XCTAssertEqual(plan.entries.map { $0.sourceProfiles.map(\.displayName) }, [["You"], ["austin"]])
}
@MainActor
func testDefaultExecutionPlanUsesSingleDestinationForSingleSourceProfile() {
let defaultProfileID = UUID(uuidString: "52B43C05-4A1D-45D3-8FD5-9EF94952E445")!
let sourceProfile = makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-single",
isDefault: true
)
let plan = BrowserImportPlanResolver.defaultPlan(
selectedSourceProfiles: [sourceProfile],
destinationProfiles: [],
preferredSingleDestinationProfileID: defaultProfileID
)
XCTAssertEqual(plan.mode, .singleDestination)
XCTAssertEqual(plan.entries.count, 1)
XCTAssertEqual(plan.entries[0].sourceProfiles.map(\.displayName), ["You"])
}
@MainActor
func testSeparatePlanReusesExistingSameNamedDestinationProfiles() {
let workID = UUID()
let destinationProfiles = [
BrowserProfileDefinition(
id: workID,
displayName: "You",
createdAt: .distantPast,
isBuiltInDefault: false
)
]
let sourceProfiles = [
makeSourceProfile(displayName: " you ", path: "/tmp/browser-import-match", isDefault: true)
]
let plan = BrowserImportPlanResolver.separateProfilesPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: destinationProfiles
)
XCTAssertEqual(plan.entries.count, 1)
XCTAssertEqual(plan.entries[0].destination, .existing(workID))
}
@MainActor
func testSeparatePlanUsesStableCreateNamesWhenTwoSourceProfilesShareDisplayName() {
let sourceProfiles = [
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-1", isDefault: true),
makeSourceProfile(displayName: "Work", path: "/tmp/browser-import-work-2", isDefault: false),
]
let plan = BrowserImportPlanResolver.separateProfilesPlan(
selectedSourceProfiles: sourceProfiles,
destinationProfiles: []
)
XCTAssertEqual(plan.entries.count, 2)
XCTAssertEqual(plan.entries[0].destination, .createNamed("Work"))
XCTAssertEqual(plan.entries[1].destination, .createNamed("Work (2)"))
}
func testStep3PresentationShowsPerProfileRowsWhenPlanUsesSeparateMode() {
let presentation = BrowserImportStep3Presentation(
plan: BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-presentation-separate",
isDefault: true
)
],
destination: .createNamed("You")
)
]
)
)
XCTAssertTrue(presentation.showsSeparateRows)
XCTAssertFalse(presentation.showsSingleDestinationPicker)
}
func testStep3PresentationShowsSingleDestinationPickerWhenPlanUsesMergeMode() {
let presentation = BrowserImportStep3Presentation(
plan: BrowserImportExecutionPlan(
mode: .mergeIntoOne,
entries: []
)
)
XCTAssertFalse(presentation.showsSeparateRows)
XCTAssertTrue(presentation.showsSingleDestinationPicker)
}
@MainActor
func testRealizePlanCreatesMissingDestinationProfilesOnlyWhenRequested() throws {
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = BrowserProfileStore(defaults: defaults)
let plan = BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-realize-create",
isDefault: true
)
],
destination: .createNamed("You")
)
]
)
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
XCTAssertEqual(realized.createdProfiles.map(\.displayName), ["You"])
XCTAssertEqual(store.profiles.map(\.displayName), ["Default", "You"])
}
@MainActor
func testRealizePlanReusesExistingProfileInsteadOfCreatingDuplicate() throws {
let suiteName = "BrowserImportMappingTests-\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defaults.removePersistentDomain(forName: suiteName)
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = BrowserProfileStore(defaults: defaults)
let existing = try XCTUnwrap(store.createProfile(named: "You"))
let plan = BrowserImportExecutionPlan(
mode: .separateProfiles,
entries: [
BrowserImportExecutionEntry(
sourceProfiles: [
makeSourceProfile(
displayName: "You",
path: "/tmp/browser-import-realize-existing",
isDefault: true
)
],
destination: .existing(existing.id)
)
]
)
let realized = try BrowserImportPlanResolver.realize(plan: plan, profileStore: store)
XCTAssertTrue(realized.createdProfiles.isEmpty)
XCTAssertEqual(realized.entries[0].destinationProfileID, existing.id)
}
func testAggregateOutcomeIncludesOneMappingLinePerDestination() {
let outcome = BrowserImportOutcome(
browserName: "Helium",
scope: .cookiesAndHistory,
domainFilters: [],
createdDestinationProfileNames: ["You", "austin"],
entries: [
BrowserImportOutcomeEntry(
sourceProfileNames: ["You"],
destinationProfileName: "You",
importedCookies: 10,
skippedCookies: 0,
importedHistoryEntries: 20,
warnings: []
),
BrowserImportOutcomeEntry(
sourceProfileNames: ["austin"],
destinationProfileName: "austin",
importedCookies: 5,
skippedCookies: 1,
importedHistoryEntries: 9,
warnings: []
),
],
warnings: []
)
let lines = BrowserImportOutcomeFormatter.lines(for: outcome)
XCTAssertTrue(lines.contains("You -> You"))
XCTAssertTrue(lines.contains("austin -> austin"))
XCTAssertTrue(lines.contains("Created cmux profiles: You, austin"))
}
private func makeSourceProfile(displayName: String, path: String, isDefault: Bool) -> InstalledBrowserProfile {
InstalledBrowserProfile(
displayName: displayName,
rootURL: URL(fileURLWithPath: path, isDirectory: true),
isDefault: isDefault
)
}
}

View file

@ -232,10 +232,10 @@ final class CJKIMEMarkedTextTests: XCTestCase {
// MARK: - selectedRange / validAttributesForMarkedText
func testSelectedRangeReturnsNotFound() {
func testSelectedRangeReturnsEmptyRangeWithoutSelection() {
let view = GhosttyNSView(frame: .zero)
let range = view.selectedRange()
XCTAssertEqual(range.location, NSNotFound)
XCTAssertEqual(range, NSRange(location: 0, length: 0))
}
func testValidAttributesForMarkedTextReturnsEmpty() {
@ -694,6 +694,68 @@ final class CJKIMEFirstRectTests: XCTestCase {
XCTAssertEqual(rect.width, 36, accuracy: 0.001)
XCTAssertEqual(rect.height, 18, accuracy: 0.001)
}
func testFirstRectUsesZeroWidthForInsertionPointWithoutOffsettingCaretAnchor() {
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
let view = GhosttyNSView(frame: frame)
view.cellSize = CGSize(width: 9, height: 18)
view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 24)
let window = NSWindow(
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
let content = NSView(frame: frame)
window.contentView = content
content.addSubview(view)
view.frame = frame
defer {
view.clearIMEPointForTesting()
window.orderOut(nil)
}
let rect = view.firstRect(forCharacterRange: NSRange(location: 5, length: 0), actualRange: nil)
let expectedViewRect = NSRect(x: 80, y: frame.height - 120, width: 0, height: 24)
let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil))
XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001)
XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001)
XCTAssertEqual(rect.width, 0, accuracy: 0.001)
XCTAssertEqual(rect.height, 24, accuracy: 0.001)
}
func testDocumentVisibleRectUsesScreenCoordinates() {
guard #available(macOS 14.0, *) else { return }
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
let view = GhosttyNSView(frame: frame)
let window = NSWindow(
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
styleMask: [.titled],
backing: .buffered,
defer: false
)
let content = NSView(frame: frame)
window.contentView = content
content.addSubview(view)
view.frame = frame
defer {
window.orderOut(nil)
}
let expected = window.convertToScreen(view.convert(view.visibleRect, to: nil))
let rect = view.documentVisibleRect
XCTAssertEqual(rect.origin.x, expected.origin.x, accuracy: 0.001)
XCTAssertEqual(rect.origin.y, expected.origin.y, accuracy: 0.001)
XCTAssertEqual(rect.width, expected.width, accuracy: 0.001)
XCTAssertEqual(rect.height, expected.height, accuracy: 0.001)
}
}
// MARK: - Key text accumulator during CJK IME composition

View file

@ -1197,6 +1197,55 @@ final class AppDelegateWindowContextRoutingTests: XCTestCase {
XCTAssertEqual(managerB.tabs.count, originalTabCountB + 1)
XCTAssertTrue(managerB.tabs.contains(where: { $0.id == createdWorkspaceId }))
}
func testApplicationOpenURLsAddsWorkspaceForDroppedFolderURL() throws {
_ = NSApplication.shared
let app = AppDelegate()
let windowId = UUID()
let window = makeMainWindow(id: windowId)
defer { window.orderOut(nil) }
let manager = TabManager()
app.registerMainWindow(
window,
windowId: windowId,
tabManager: manager,
sidebarState: SidebarState(),
sidebarSelectionState: SidebarSelectionState()
)
window.makeKeyAndOrderFront(nil)
_ = app.synchronizeActiveMainWindowContext(preferredWindow: window)
let defaults = UserDefaults.standard
let previousWelcomeShown = defaults.object(forKey: WelcomeSettings.shownKey)
defaults.set(true, forKey: WelcomeSettings.shownKey)
defer {
if let previousWelcomeShown {
defaults.set(previousWelcomeShown, forKey: WelcomeSettings.shownKey)
} else {
defaults.removeObject(forKey: WelcomeSettings.shownKey)
}
}
let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let droppedDirectory = rootDirectory.appendingPathComponent("project", isDirectory: true)
try FileManager.default.createDirectory(at: droppedDirectory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: rootDirectory) }
let existingWorkspaceIds = Set(manager.tabs.map(\.id))
app.application(
NSApplication.shared,
open: [URL(fileURLWithPath: droppedDirectory.path)]
)
let createdWorkspace = manager.tabs.first { !existingWorkspaceIds.contains($0.id) }
XCTAssertNotNil(createdWorkspace)
XCTAssertEqual(createdWorkspace?.currentDirectory, droppedDirectory.path)
}
}
@MainActor
@ -2617,6 +2666,113 @@ final class BrowserNavigationNewTabDecisionTests: XCTestCase {
}
}
final class BrowserPopupDecisionTests: XCTestCase {
func testLinkActivatedPlainLeftClickDoesNotCreatePopup() {
XCTAssertFalse(
browserNavigationShouldCreatePopup(
navigationType: .linkActivated,
modifierFlags: [],
buttonNumber: 0
)
)
}
func testOtherNavigationPlainLeftClickCreatesPopup() {
XCTAssertTrue(
browserNavigationShouldCreatePopup(
navigationType: .other,
modifierFlags: [],
buttonNumber: 0
)
)
}
func testOtherNavigationMiddleClickDoesNotCreatePopup() {
XCTAssertFalse(
browserNavigationShouldCreatePopup(
navigationType: .other,
modifierFlags: [],
buttonNumber: 2
)
)
}
func testLinkActivatedCmdClickDoesNotCreatePopup() {
XCTAssertFalse(
browserNavigationShouldCreatePopup(
navigationType: .linkActivated,
modifierFlags: [.command],
buttonNumber: 0
)
)
}
}
final class BrowserNilTargetFallbackDecisionTests: XCTestCase {
func testOtherNavigationDoesNotFallbackToNewTab() {
XCTAssertFalse(
browserNavigationShouldFallbackNilTargetToNewTab(
navigationType: .other
)
)
}
func testLinkActivatedNavigationFallsBackToNewTab() {
XCTAssertTrue(
browserNavigationShouldFallbackNilTargetToNewTab(
navigationType: .linkActivated
)
)
}
}
final class BrowserPopupContentRectTests: XCTestCase {
func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() {
let rect = browserPopupContentRect(
requestedWidth: 400,
requestedHeight: 300,
requestedX: 150,
requestedTopY: 120,
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
)
XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01)
XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01)
XCTAssertEqual(rect.width, 400, accuracy: 0.01)
XCTAssertEqual(rect.height, 300, accuracy: 0.01)
}
func testExplicitCoordinatesClampToVisibleFrame() {
let rect = browserPopupContentRect(
requestedWidth: 1400,
requestedHeight: 1200,
requestedX: 900,
requestedTopY: -25,
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
)
XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01)
XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01)
XCTAssertEqual(rect.width, 1000, accuracy: 0.01)
XCTAssertEqual(rect.height, 800, accuracy: 0.01)
}
func testMissingCoordinatesCentersPopup() {
let rect = browserPopupContentRect(
requestedWidth: 300,
requestedHeight: 200,
requestedX: nil,
requestedTopY: nil,
visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800)
)
XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01)
XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01)
XCTAssertEqual(rect.width, 300, accuracy: 0.01)
XCTAssertEqual(rect.height, 200, accuracy: 0.01)
}
}
@MainActor
final class BrowserJavaScriptDialogDelegateTests: XCTestCase {
func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() {
@ -5125,6 +5281,32 @@ final class WorkspaceReorderTests: XCTestCase {
let manager = TabManager()
XCTAssertFalse(manager.reorderWorkspace(tabId: UUID(), toIndex: 0))
}
@MainActor
func testReorderWorkspaceKeepsUnpinnedWorkspaceBelowPinnedSegment() {
let manager = TabManager()
let firstPinned = manager.tabs[0]
manager.setPinned(firstPinned, pinned: true)
let secondPinned = manager.addWorkspace()
manager.setPinned(secondPinned, pinned: true)
let unpinned = manager.addWorkspace()
XCTAssertTrue(manager.reorderWorkspace(tabId: unpinned.id, toIndex: 0))
XCTAssertEqual(manager.tabs.map(\.id), [firstPinned.id, secondPinned.id, unpinned.id])
}
@MainActor
func testReorderWorkspaceKeepsPinnedWorkspaceInsidePinnedSegment() {
let manager = TabManager()
let firstPinned = manager.tabs[0]
manager.setPinned(firstPinned, pinned: true)
let secondPinned = manager.addWorkspace()
manager.setPinned(secondPinned, pinned: true)
let unpinned = manager.addWorkspace()
XCTAssertTrue(manager.reorderWorkspace(tabId: firstPinned.id, toIndex: 999))
XCTAssertEqual(manager.tabs.map(\.id), [secondPinned.id, firstPinned.id, unpinned.id])
}
}
@MainActor
@ -7152,14 +7334,16 @@ final class SidebarDropPlannerTests: XCTestCase {
SidebarDropPlanner.indicator(
draggedTabId: first,
targetTabId: first,
tabIds: tabIds
tabIds: tabIds,
pinnedTabIds: []
)
)
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: third,
targetTabId: nil,
tabIds: tabIds
tabIds: tabIds,
pinnedTabIds: []
)
)
}
@ -7170,14 +7354,16 @@ final class SidebarDropPlannerTests: XCTestCase {
SidebarDropPlanner.indicator(
draggedTabId: only,
targetTabId: nil,
tabIds: [only]
tabIds: [only],
pinnedTabIds: []
)
)
XCTAssertNil(
SidebarDropPlanner.indicator(
draggedTabId: only,
targetTabId: only,
tabIds: [only]
tabIds: [only],
pinnedTabIds: []
)
)
}
@ -7191,7 +7377,8 @@ final class SidebarDropPlannerTests: XCTestCase {
let indicator = SidebarDropPlanner.indicator(
draggedTabId: second,
targetTabId: nil,
tabIds: tabIds
tabIds: tabIds,
pinnedTabIds: []
)
XCTAssertEqual(indicator?.tabId, nil)
XCTAssertEqual(indicator?.edge, .bottom)
@ -7207,7 +7394,8 @@ final class SidebarDropPlannerTests: XCTestCase {
draggedTabId: second,
targetTabId: nil,
indicator: SidebarDropIndicator(tabId: nil, edge: .bottom),
tabIds: tabIds
tabIds: tabIds,
pinnedTabIds: []
)
XCTAssertEqual(index, 2)
}
@ -7222,7 +7410,8 @@ final class SidebarDropPlannerTests: XCTestCase {
SidebarDropPlanner.indicator(
draggedTabId: second,
targetTabId: second,
tabIds: tabIds
tabIds: tabIds,
pinnedTabIds: []
)
)
}
@ -7238,6 +7427,7 @@ final class SidebarDropPlannerTests: XCTestCase {
draggedTabId: first,
targetTabId: second,
tabIds: tabIds,
pinnedTabIds: [],
pointerY: 2,
targetHeight: 40
)
@ -7254,6 +7444,7 @@ final class SidebarDropPlannerTests: XCTestCase {
draggedTabId: first,
targetTabId: second,
tabIds: tabIds,
pinnedTabIds: [],
pointerY: 38,
targetHeight: 40
)
@ -7264,7 +7455,8 @@ final class SidebarDropPlannerTests: XCTestCase {
draggedTabId: first,
targetTabId: second,
indicator: indicator,
tabIds: tabIds
tabIds: tabIds,
pinnedTabIds: []
),
1
)
@ -7280,6 +7472,7 @@ final class SidebarDropPlannerTests: XCTestCase {
draggedTabId: third,
targetTabId: first,
tabIds: tabIds,
pinnedTabIds: [],
pointerY: 38,
targetHeight: 40
)
@ -7287,6 +7480,7 @@ final class SidebarDropPlannerTests: XCTestCase {
draggedTabId: third,
targetTabId: second,
tabIds: tabIds,
pinnedTabIds: [],
pointerY: 2,
targetHeight: 40
)
@ -7308,11 +7502,53 @@ final class SidebarDropPlannerTests: XCTestCase {
draggedTabId: third,
targetTabId: second,
tabIds: tabIds,
pinnedTabIds: [],
pointerY: 38,
targetHeight: 40
)
)
}
func testIndicatorSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
let pinnedA = UUID()
let pinnedB = UUID()
let unpinnedA = UUID()
let unpinnedB = UUID()
let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
let pinnedIds: Set<UUID> = [pinnedA, pinnedB]
let indicator = SidebarDropPlanner.indicator(
draggedTabId: unpinnedB,
targetTabId: pinnedA,
tabIds: tabIds,
pinnedTabIds: pinnedIds,
pointerY: 2,
targetHeight: 40
)
XCTAssertEqual(indicator?.tabId, unpinnedA)
XCTAssertEqual(indicator?.edge, .top)
}
func testTargetIndexSnapsUnpinnedDropToFirstUnpinnedBoundaryWhenHoveringPinnedWorkspace() {
let pinnedA = UUID()
let pinnedB = UUID()
let unpinnedA = UUID()
let unpinnedB = UUID()
let tabIds = [pinnedA, pinnedB, unpinnedA, unpinnedB]
let pinnedIds: Set<UUID> = [pinnedA, pinnedB]
let targetIndex = SidebarDropPlanner.targetIndex(
draggedTabId: unpinnedB,
targetTabId: pinnedA,
indicator: SidebarDropIndicator(tabId: pinnedA, edge: .top),
tabIds: tabIds,
pinnedTabIds: pinnedIds
)
XCTAssertEqual(targetIndex, 2)
}
}
final class SidebarDragAutoScrollPlannerTests: XCTestCase {
@ -12055,6 +12291,18 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
return nil
}
private func firstResponderOwnsTextField(_ firstResponder: NSResponder?, textField: NSTextField) -> Bool {
if firstResponder === textField {
return true
}
if let editor = firstResponder as? NSTextView,
editor.isFieldEditor,
editor.delegate as? NSTextField === textField {
return true
}
return false
}
func testTrackpadScrollRoutesToTerminalSurfaceAndPreservesKeyboardFocusPath() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
@ -12187,12 +12435,105 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
let searchState = TerminalSurface.SearchState(needle: "example")
hostedView.setSearchOverlay(searchState: searchState)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(hostedView.debugHasSearchOverlay())
hostedView.setSearchOverlay(searchState: nil)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertFalse(hostedView.debugHasSearchOverlay())
}
func testRapidSearchOverlayToggleDoesNotLeaveStaleOverlayMounted() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "example"))
hostedView.setSearchOverlay(searchState: nil)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertFalse(
hostedView.debugHasSearchOverlay(),
"A stale deferred mount must not resurrect the find overlay after it closes"
)
}
func testSearchOverlayFocusesSearchFieldAfterDeferredAttach() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.setVisibleInUI(true)
hostedView.setActive(true)
let searchState = TerminalSurface.SearchState(needle: "")
surface.searchState = searchState
hostedView.setSearchOverlay(searchState: searchState)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let searchField = findEditableTextField(in: hostedView) else {
XCTFail("Expected mounted find text field")
return
}
XCTAssertTrue(
firstResponderOwnsTextField(window.firstResponder, textField: searchField),
"Deferred search overlay attach should still move focus into the find field"
)
}
func testStartOrFocusTerminalSearchReusesExistingSearchState() {
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let existingSearchState = TerminalSurface.SearchState(needle: "existing")
surface.searchState = existingSearchState
var focusNotificationCount = 0
XCTAssertTrue(
startOrFocusTerminalSearch(surface) { _ in
focusNotificationCount += 1
}
)
XCTAssertTrue(surface.searchState === existingSearchState)
XCTAssertEqual(
focusNotificationCount,
1,
"Re-triggering terminal Find should refocus the existing overlay without recreating state"
)
}
func testEscapeDismissingFindOverlayDoesNotLeakEscapeKeyUpToTerminal() {
_ = NSApplication.shared
@ -12428,6 +12769,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
)
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "split"))
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(hostedView.debugHasSearchOverlay())
portal.bind(hostedView: hostedView, to: anchorA, visibleInUI: true)
@ -12466,6 +12808,7 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
)
let hostedView = surface.hostedView
hostedView.setSearchOverlay(searchState: TerminalSurface.SearchState(needle: "workspace"))
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(hostedView.debugHasSearchOverlay())
portal.bind(hostedView: hostedView, to: anchor, visibleInUI: true)

View file

@ -434,6 +434,78 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
)
}
func testVisibleResultsResetWhenQueryChangesCommandPaletteScope() {
XCTAssertTrue(
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
oldQuery: ">",
newQuery: "",
hasVisibleResults: true
)
)
XCTAssertTrue(
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
oldQuery: "",
newQuery: ">",
hasVisibleResults: true
)
)
XCTAssertFalse(
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
oldQuery: ">rename",
newQuery: ">renam",
hasVisibleResults: true
)
)
XCTAssertFalse(
ContentView.commandPaletteShouldResetVisibleResultsForQueryTransition(
oldQuery: ">",
newQuery: "",
hasVisibleResults: false
)
)
}
func testRefreshInputsPreferObservedQueryOverStaleState() {
let inputs = ContentView.commandPaletteRefreshInputsForTests(
stateQuery: ">",
observedQuery: "",
searchAllSurfaces: true
)
XCTAssertEqual(inputs.scope, "switcher")
XCTAssertEqual(inputs.matchingQuery, "")
XCTAssertFalse(inputs.includesSurfaces)
}
func testRefreshInputsIncludeSurfacesOnlyForNonEmptySwitcherQuery() {
let switcherInputs = ContentView.commandPaletteRefreshInputsForTests(
stateQuery: "",
observedQuery: " feature/search ",
searchAllSurfaces: true
)
XCTAssertEqual(switcherInputs.scope, "switcher")
XCTAssertEqual(switcherInputs.matchingQuery, "feature/search")
XCTAssertTrue(switcherInputs.includesSurfaces)
let commandInputs = ContentView.commandPaletteRefreshInputsForTests(
stateQuery: "",
observedQuery: ">feature/search",
searchAllSurfaces: true
)
XCTAssertEqual(commandInputs.scope, "commands")
XCTAssertEqual(commandInputs.matchingQuery, "feature/search")
XCTAssertFalse(commandInputs.includesSurfaces)
let workspaceOnlyInputs = ContentView.commandPaletteRefreshInputsForTests(
stateQuery: "",
observedQuery: "feature/search",
searchAllSurfaces: false
)
XCTAssertEqual(workspaceOnlyInputs.scope, "switcher")
XCTAssertEqual(workspaceOnlyInputs.matchingQuery, "feature/search")
XCTAssertFalse(workspaceOnlyInputs.includesSurfaces)
}
func testCommandContextFingerprintTracksExactContextValues() {
let base = ContentView.commandPaletteContextFingerprint(
boolValues: [
@ -490,7 +562,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
directories: ["/Users/example/dev/cmuxterm"],
branches: ["feature/search-speed"],
ports: [3000]
)
),
surfaces: []
)
]
)
@ -510,7 +583,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
directories: ["/Users/example/dev/other"],
branches: ["feature/search-speed"],
ports: [4000]
)
),
surfaces: []
)
]
)
@ -530,7 +604,8 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
directories: ["/Users/example/dev/cmuxterm"],
branches: ["feature/search-speed"],
ports: [3000]
)
),
surfaces: []
)
]
)
@ -541,6 +616,100 @@ final class CommandPaletteSearchEngineTests: XCTestCase {
XCTAssertNotEqual(base, changedDisplayName)
}
func testSwitcherFingerprintTracksSurfaceValuesAtSameCardinality() {
let windowID = UUID()
let workspaceID = UUID()
let surfaceID = UUID()
let base = ContentView.commandPaletteSwitcherFingerprint(
windowContexts: [
ContentView.CommandPaletteSwitcherFingerprintContext(
windowId: windowID,
windowLabel: nil,
selectedWorkspaceId: workspaceID,
workspaces: [
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
id: workspaceID,
displayName: "Workspace Alpha",
metadata: CommandPaletteSwitcherSearchMetadata(),
surfaces: [
ContentView.CommandPaletteSwitcherFingerprintSurface(
id: surfaceID,
displayName: "Terminal",
kindLabel: "Terminal",
metadata: CommandPaletteSwitcherSearchMetadata(
directories: ["/tmp/search-alpha"],
branches: ["feature/a"],
ports: [3000]
)
)
]
)
]
)
]
)
let changedSurfaceMetadata = ContentView.commandPaletteSwitcherFingerprint(
windowContexts: [
ContentView.CommandPaletteSwitcherFingerprintContext(
windowId: windowID,
windowLabel: nil,
selectedWorkspaceId: workspaceID,
workspaces: [
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
id: workspaceID,
displayName: "Workspace Alpha",
metadata: CommandPaletteSwitcherSearchMetadata(),
surfaces: [
ContentView.CommandPaletteSwitcherFingerprintSurface(
id: surfaceID,
displayName: "Terminal",
kindLabel: "Terminal",
metadata: CommandPaletteSwitcherSearchMetadata(
directories: ["/tmp/search-beta"],
branches: ["feature/a"],
ports: [3000]
)
)
]
)
]
)
]
)
let changedSurfaceKind = ContentView.commandPaletteSwitcherFingerprint(
windowContexts: [
ContentView.CommandPaletteSwitcherFingerprintContext(
windowId: windowID,
windowLabel: nil,
selectedWorkspaceId: workspaceID,
workspaces: [
ContentView.CommandPaletteSwitcherFingerprintWorkspace(
id: workspaceID,
displayName: "Workspace Alpha",
metadata: CommandPaletteSwitcherSearchMetadata(),
surfaces: [
ContentView.CommandPaletteSwitcherFingerprintSurface(
id: surfaceID,
displayName: "Terminal",
kindLabel: "Browser",
metadata: CommandPaletteSwitcherSearchMetadata(
directories: ["/tmp/search-alpha"],
branches: ["feature/a"],
ports: [3000]
)
)
]
)
]
)
]
)
XCTAssertNotEqual(base, changedSurfaceMetadata)
XCTAssertNotEqual(base, changedSurfaceKind)
}
func testCommandSearchBenchmarkBeatsLegacyPipeline() {
let entries = makeCommandEntries(count: 900)
let corpus = entries.map { entry in

View file

@ -47,19 +47,6 @@ final class GhosttyConfigTests: XCTestCase {
let blue: Int
}
private func writeAppSupportConfig(
root: URL,
bundleIdentifier: String,
name: String = "config",
contents: String = "font-size = 14\n"
) throws -> URL {
let directory = root.appendingPathComponent(bundleIdentifier, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(name, isDirectory: false)
try contents.write(to: url, atomically: true, encoding: .utf8)
return url
}
func testResolveThemeNamePrefersLightEntryForPairedTheme() {
let resolved = GhosttyConfig.resolveThemeName(
from: "light:Builtin Solarized Light,dark:Builtin Solarized Dark",
@ -324,71 +311,84 @@ final class GhosttyConfigTests: XCTestCase {
)
}
func testReleaseAppSupportFallbackLoadsForDebugWhenOnlyReleaseConfigExists() {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
func testCmuxAppSupportConfigURLsUseReleaseConfigForDebugBundleWithoutCurrentConfig() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
let releaseConfigURL = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config",
contents: "font-size = 13\n"
)
let releaseURL = try? writeAppSupportConfig(
root: root,
bundleIdentifier: "com.cmuxterm.app"
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
appSupportDirectory: root
),
[releaseURL].compactMap { $0 }
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
appSupportDirectory: appSupportDirectory
),
[releaseConfigURL]
)
}
}
func testReleaseAppSupportFallbackSkipsWhenDebugConfigAlreadyExists() {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
func testCmuxAppSupportConfigURLsPreferCurrentBundleConfigWhenPresent() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
_ = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config",
contents: "font-size = 13\n"
)
let currentConfigURL = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
filename: "config.ghostty",
contents: "font-size = 14\n"
)
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
let debugURL = try? writeAppSupportConfig(
root: root,
bundleIdentifier: "com.cmuxterm.app.debug.issue-829",
name: "config.ghostty"
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
appSupportDirectory: root
),
[debugURL].compactMap { $0 }
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug.issue-829",
appSupportDirectory: appSupportDirectory
),
[currentConfigURL]
)
}
}
func testReleaseAppSupportFallbackSkipsForNonDebugBundleOrMissingReleaseConfig() {
let root = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-release-config-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
func testCmuxAppSupportConfigURLsSkipReleaseFallbackForNonDebugBundle() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
_ = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config",
contents: "font-size = 13\n"
)
_ = try? writeAppSupportConfig(root: root, bundleIdentifier: "com.cmuxterm.app")
XCTAssertTrue(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.example.other-app",
appSupportDirectory: appSupportDirectory
).isEmpty
)
}
}
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app",
appSupportDirectory: root
).count,
1
)
func testCmuxAppSupportConfigURLsIgnoreMissingOrEmptyFiles() throws {
try withTemporaryAppSupportDirectory { appSupportDirectory in
_ = try writeAppSupportConfig(
appSupportDirectory: appSupportDirectory,
bundleIdentifier: "com.cmuxterm.app",
filename: "config.ghostty",
contents: ""
)
XCTAssertEqual(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
appSupportDirectory: root.appendingPathComponent("missing", isDirectory: true)
),
[]
)
XCTAssertTrue(
GhosttyApp.cmuxAppSupportConfigURLs(
currentBundleIdentifier: "com.cmuxterm.app.debug",
appSupportDirectory: appSupportDirectory
).isEmpty
)
}
}
func testDefaultBackgroundUpdateScopePrioritizesSurfaceOverAppAndUnscoped() {
@ -597,6 +597,33 @@ final class GhosttyConfigTests: XCTestCase {
blue: Int(round(blue * 255))
)
}
private func withTemporaryAppSupportDirectory(
_ body: (URL) throws -> Void
) throws {
let fileManager = FileManager.default
let directory = fileManager.temporaryDirectory
.appendingPathComponent("cmux-app-support-\(UUID().uuidString)", isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? fileManager.removeItem(at: directory) }
try body(directory)
}
private func writeAppSupportConfig(
appSupportDirectory: URL,
bundleIdentifier: String,
filename: String,
contents: String
) throws -> URL {
let fileManager = FileManager.default
let bundleDirectory = appSupportDirectory
.appendingPathComponent(bundleIdentifier, isDirectory: true)
try fileManager.createDirectory(at: bundleDirectory, withIntermediateDirectories: true)
let configURL = bundleDirectory.appendingPathComponent(filename, isDirectory: false)
try contents.write(to: configURL, atomically: true, encoding: .utf8)
return configURL
}
}
final class WorkspaceChromeThemeTests: XCTestCase {
@ -1447,6 +1474,7 @@ final class RecentlyClosedBrowserStackTests: XCTestCase {
ClosedBrowserPanelRestoreSnapshot(
workspaceId: UUID(),
url: URL(string: "https://example.com/\(index)"),
profileID: nil,
originalPaneId: UUID(),
originalTabIndex: index,
fallbackSplitOrientation: .horizontal,
@ -2018,6 +2046,157 @@ final class GhosttyMouseFocusTests: XCTestCase {
}
}
final class SidebarBackgroundConfigTests: XCTestCase {
func testParseSidebarBackgroundSingleHex() {
var config = GhosttyConfig()
config.parse("sidebar-background = #336699")
XCTAssertEqual(config.rawSidebarBackground, "#336699")
}
func testParseSidebarBackgroundDualMode() {
var config = GhosttyConfig()
config.parse("sidebar-background = light:#fbf3db,dark:#103c48")
XCTAssertEqual(config.rawSidebarBackground, "light:#fbf3db,dark:#103c48")
}
func testParseSidebarTintOpacity() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = 0.4")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.4, accuracy: 0.0001)
}
func testParseSidebarTintOpacityClampedAboveOne() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = 1.5")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 1.0, accuracy: 0.0001)
}
func testParseSidebarTintOpacityClampedBelowZero() {
var config = GhosttyConfig()
config.parse("sidebar-tint-opacity = -0.3")
XCTAssertEqual(config.sidebarTintOpacity ?? -1, 0.0, accuracy: 0.0001)
}
func testResolveSidebarBackgroundSingleHex() {
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
XCTAssertNotNil(config.sidebarBackground)
XCTAssertNil(config.sidebarBackgroundLight)
XCTAssertNil(config.sidebarBackgroundDark)
}
func testResolveSidebarBackgroundDualModeSetsLightAndDark() {
var config = GhosttyConfig()
config.rawSidebarBackground = "light:#fbf3db,dark:#103c48"
config.resolveSidebarBackground(preferredColorScheme: .light)
XCTAssertNotNil(config.sidebarBackgroundLight)
XCTAssertNotNil(config.sidebarBackgroundDark)
XCTAssertNotNil(config.sidebarBackground)
}
func testResolveSidebarBackgroundNilWhenNoRaw() {
var config = GhosttyConfig()
config.resolveSidebarBackground(preferredColorScheme: .dark)
XCTAssertNil(config.sidebarBackground)
XCTAssertNil(config.sidebarBackgroundLight)
XCTAssertNil(config.sidebarBackgroundDark)
}
func testApplyToUserDefaultsSkipsWritesWhenNoConfig() {
let defaults = UserDefaults.standard
let testKey = "sidebarTintHex"
let original = defaults.string(forKey: testKey)
defer { restoreDefaultsValue(original, key: testKey, defaults: defaults) }
defaults.set("#AAAAAA", forKey: testKey)
var config = GhosttyConfig()
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: testKey), "#AAAAAA",
"Should not overwrite UserDefaults when rawSidebarBackground is nil")
}
func testApplyToUserDefaultsWritesHexWhenConfigSet() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#336699")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"))
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"))
}
func testApplyToUserDefaultsClearsStaleKeysOnSwitchFromDualToSingle() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
defaults.set("#AAAAAA", forKey: "sidebarTintHexLight")
defaults.set("#BBBBBB", forKey: "sidebarTintHexDark")
var config = GhosttyConfig()
config.rawSidebarBackground = "#222222"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.string(forKey: "sidebarTintHex"), "#222222")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexLight"),
"Stale light key should be cleared")
XCTAssertNil(defaults.string(forKey: "sidebarTintHexDark"),
"Stale dark key should be cleared")
}
func testApplyToUserDefaultsOnlyWritesOpacityWhenExplicit() {
let defaults = UserDefaults.standard
let keys = ["sidebarTintHex", "sidebarTintHexLight", "sidebarTintHexDark", "sidebarTintOpacity"]
let originals = keys.map { defaults.object(forKey: $0) }
defer {
for (key, original) in zip(keys, originals) {
restoreDefaultsValue(original, key: key, defaults: defaults)
}
}
defaults.set(0.18, forKey: "sidebarTintOpacity")
var config = GhosttyConfig()
config.rawSidebarBackground = "#336699"
config.resolveSidebarBackground(preferredColorScheme: .light)
config.applySidebarAppearanceToUserDefaults()
XCTAssertEqual(defaults.double(forKey: "sidebarTintOpacity"), 0.18, accuracy: 0.0001,
"Should not overwrite opacity when config doesn't set sidebar-tint-opacity")
}
private func restoreDefaultsValue(_ value: Any?, key: String, defaults: UserDefaults) {
if let value = value {
defaults.set(value, forKey: key)
} else {
defaults.removeObject(forKey: key)
}
}
}
final class ZshShellIntegrationHandoffTests: XCTestCase {
func testGhosttyPromptHooksLoadWhenCmuxRequestsZshIntegration() throws {
let output = try runInteractiveZsh(cmuxLoadGhosttyIntegration: true)
@ -2133,3 +2312,232 @@ final class ZshShellIntegrationHandoffTests: XCTestCase {
return output.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
final class BrowserInstallDetectorTests: XCTestCase {
func testDetectInstalledBrowsersUsesBundleIdAndProfileData() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Google/Chrome/Default/History"),
contents: Data()
)
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Firefox/Profiles/dev.default-release/cookies.sqlite"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { bundleIdentifier in
if bundleIdentifier == "com.google.Chrome" {
return URL(fileURLWithPath: "/Applications/Google Chrome.app", isDirectory: true)
}
return nil
},
applicationSearchDirectories: []
)
guard let chrome = detected.first(where: { $0.descriptor.id == "google-chrome" }) else {
XCTFail("Expected Chrome to be detected")
return
}
guard let firefox = detected.first(where: { $0.descriptor.id == "firefox" }) else {
XCTFail("Expected Firefox to be detected from profile data")
return
}
XCTAssertNotNil(chrome.appURL)
XCTAssertEqual(firefox.profileURLs.count, 1)
XCTAssertNil(firefox.appURL)
}
func testDetectInstalledBrowsersReturnsEmptyWhenNoSignalsExist() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.isEmpty)
}
func testUngoogledChromiumRequiresAppSignal() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home
.appendingPathComponent("Library/Application Support/Chromium/Default/History"),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
XCTAssertTrue(detected.contains(where: { $0.descriptor.id == "chromium" }))
XCTAssertFalse(detected.contains(where: { $0.descriptor.id == "ungoogled-chromium" }))
}
func testDetectInstalledBrowsersDiscoversHeliumProfilesFromChromiumLayout() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
let heliumRoot = home.appendingPathComponent("Library/Application Support/net.imput.helium", isDirectory: true)
try createFile(
at: heliumRoot.appendingPathComponent("Default/History"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Profile 1/Cookies"),
contents: Data()
)
try createFile(
at: heliumRoot.appendingPathComponent("Local State"),
contents: Data(
"""
{
"profile": {
"info_cache": {
"Default": {
"name": "Personal"
},
"Profile 1": {
"name": "Work"
}
}
}
}
""".utf8
)
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let helium = detected.first(where: { $0.descriptor.id == "helium" }) else {
XCTFail("Expected Helium to be detected")
return
}
XCTAssertEqual(helium.family, .chromium)
XCTAssertEqual(helium.profiles.map(\.displayName), ["Personal", "Work"])
XCTAssertEqual(
helium.profiles.map(\.rootURL.lastPathComponent),
["Default", "Profile 1"]
)
}
func testDetectInstalledBrowsersDiscoversSafariProfiles() throws {
let home = makeTemporaryHome()
defer { try? FileManager.default.removeItem(at: home) }
try createFile(
at: home.appendingPathComponent("Library/Safari/History.db"),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Safari/Profiles/Work/History.db"
),
contents: Data()
)
try createFile(
at: home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel/History.db"
),
contents: Data()
)
let detected = InstalledBrowserDetector.detectInstalledBrowsers(
homeDirectoryURL: home,
bundleLookup: { _ in nil },
applicationSearchDirectories: []
)
guard let safari = detected.first(where: { $0.descriptor.id == "safari" }) else {
XCTFail("Expected Safari to be detected")
return
}
XCTAssertEqual(safari.profiles.map(\.displayName), ["Default", "Work", "Travel"])
XCTAssertEqual(
safari.profiles.map { $0.rootURL.path(percentEncoded: false) }.sorted(),
[
home.appendingPathComponent("Library/Safari", isDirectory: true).path(percentEncoded: false),
home.appendingPathComponent("Library/Safari/Profiles/Work", isDirectory: true).path(percentEncoded: false),
home.appendingPathComponent(
"Library/Containers/com.apple.Safari/Data/Library/Safari/Profiles/Travel",
isDirectory: true
).path(percentEncoded: false),
].sorted()
)
}
private func makeTemporaryHome() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent("cmux-browser-detect-\(UUID().uuidString)")
}
private func createFile(at url: URL, contents: Data) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
_ = FileManager.default.createFile(atPath: url.path, contents: contents)
}
}
final class BrowserImportScopeTests: XCTestCase {
func testFromSelectionCookiesOnly() {
let scope = BrowserImportScope.fromSelection(
includeCookies: true,
includeHistory: false,
includeAdditionalData: false
)
XCTAssertEqual(scope, .cookiesOnly)
}
func testFromSelectionHistoryOnly() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: true,
includeAdditionalData: false
)
XCTAssertEqual(scope, .historyOnly)
}
func testFromSelectionCookiesAndHistory() {
let scope = BrowserImportScope.fromSelection(
includeCookies: true,
includeHistory: true,
includeAdditionalData: false
)
XCTAssertEqual(scope, .cookiesAndHistory)
}
func testFromSelectionEverything() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: false,
includeAdditionalData: true
)
XCTAssertEqual(scope, .everything)
}
func testFromSelectionRejectsEmptySelection() {
let scope = BrowserImportScope.fromSelection(
includeCookies: false,
includeHistory: false,
includeAdditionalData: false
)
XCTAssertNil(scope)
}
}

View file

@ -184,8 +184,10 @@ final class SessionPersistenceTests: XCTestCase {
}
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
let profileID = UUID(uuidString: "8F03A658-5A84-428B-AD03-5A6D04692F64")
let source = SessionBrowserPanelSnapshot(
urlString: "https://example.com/current",
profileID: profileID,
shouldRenderWebView: true,
pageZoom: 1.2,
developerToolsVisible: true,
@ -201,6 +203,7 @@ final class SessionPersistenceTests: XCTestCase {
let data = try JSONEncoder().encode(source)
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
XCTAssertEqual(decoded.urlString, source.urlString)
XCTAssertEqual(decoded.profileID, source.profileID)
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
}
@ -217,6 +220,7 @@ final class SessionPersistenceTests: XCTestCase {
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
XCTAssertEqual(decoded.urlString, "https://example.com/current")
XCTAssertNil(decoded.profileID)
XCTAssertNil(decoded.backHistoryURLStrings)
XCTAssertNil(decoded.forwardHistoryURLStrings)
}

View file

@ -0,0 +1,17 @@
import XCTest
#if canImport(cmux_DEV)
@testable import cmux_DEV
#elseif canImport(cmux)
@testable import cmux
#endif
final class SidebarWidthPolicyTests: XCTestCase {
func testContentViewClampAllowsNarrowSidebarBelowLegacyMinimum() {
XCTAssertEqual(
ContentView.clampedSidebarWidth(184, maximumWidth: 600),
184,
accuracy: 0.001
)
}
}

View file

@ -0,0 +1,159 @@
import XCTest
import Foundation
final class BrowserImportProfilesUITests: XCTestCase {
private var capturePath = ""
override func setUp() {
super.setUp()
continueAfterFailure = false
capturePath = "/tmp/cmux-ui-test-browser-import-\(UUID().uuidString).json"
try? FileManager.default.removeItem(atPath: capturePath)
}
func testMultipleSourceProfilesDefaultToSeparateDestinations() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
XCTAssertTrue(
app.radioButtons["Keep profiles separate"].waitForExistence(timeout: 5.0),
"Expected Step 3 to show the separate-profiles default"
)
XCTAssertTrue(app.radioButtons["Merge all into one cmux profile"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-you"].exists)
XCTAssertTrue(app.popUpButtons["BrowserImportDestinationPopup-austin"].exists)
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["mode"] as? String, "separateProfiles")
XCTAssertEqual(capture["scope"] as? String, "cookiesAndHistory")
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
XCTAssertEqual(entries.count, 2)
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You"])
XCTAssertEqual(entries[0]["destinationKind"] as? String, "create")
XCTAssertEqual(entries[0]["destinationName"] as? String, "You")
XCTAssertEqual(entries[1]["sourceProfiles"] as? [String], ["austin"])
XCTAssertEqual(entries[1]["destinationKind"] as? String, "create")
XCTAssertEqual(entries[1]["destinationName"] as? String, "austin")
}
func testMergeModeCapturesSingleMergedDestination() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
let mergeRadio = app.radioButtons["Merge all into one cmux profile"]
XCTAssertTrue(mergeRadio.waitForExistence(timeout: 5.0))
mergeRadio.click()
XCTAssertTrue(
app.popUpButtons["BrowserImportDestinationPopup-merge"].waitForExistence(timeout: 5.0),
"Expected merge mode to show the single destination popup"
)
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["mode"] as? String, "mergeIntoOne")
let entries = try XCTUnwrap(capture["entries"] as? [[String: Any]])
XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries[0]["sourceProfiles"] as? [String], ["You", "austin"])
XCTAssertEqual(entries[0]["destinationKind"] as? String, "existing")
XCTAssertEqual(entries[0]["destinationName"] as? String, "Default")
}
func testAdditionalDataSelectionCapturesEverythingScope() throws {
let app = launchApp()
openImportWizard(app)
app.buttons["Next"].click()
app.buttons["Next"].click()
let cookiesCheckbox = app.checkBoxes["BrowserImportCookiesCheckbox"]
XCTAssertTrue(cookiesCheckbox.waitForExistence(timeout: 5.0))
cookiesCheckbox.click()
let historyCheckbox = app.checkBoxes["BrowserImportHistoryCheckbox"]
XCTAssertTrue(historyCheckbox.waitForExistence(timeout: 5.0))
historyCheckbox.click()
let additionalDataCheckbox = app.checkBoxes["BrowserImportAdditionalDataCheckbox"]
XCTAssertTrue(
additionalDataCheckbox.waitForExistence(timeout: 5.0),
"Expected Step 3 to expose the additional data checkbox"
)
additionalDataCheckbox.click()
app.buttons["Start Import"].click()
let capture = try XCTUnwrap(waitForCapturedSelection(timeout: 5.0))
XCTAssertEqual(capture["scope"] as? String, "everything")
}
private func launchApp() -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_FIXTURE"] = #"{"browserName":"Helium","profiles":["You","austin"]}"#
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_DESTINATIONS"] = #"["Default"]"#
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_MODE"] = "capture-only"
app.launchEnvironment["CMUX_UI_TEST_BROWSER_IMPORT_CAPTURE_PATH"] = capturePath
app.launch()
XCTAssertTrue(
ensureForegroundAfterLaunch(app, timeout: 12.0),
"Expected app to launch in the foreground for browser import UI tests"
)
return app
}
private func openImportWizard(_ app: XCUIApplication) {
let viewMenu = app.menuBars.menuBarItems["View"].firstMatch
XCTAssertTrue(viewMenu.waitForExistence(timeout: 5.0), "Expected View menu to exist")
viewMenu.click()
let importItem = app.menuItems["Import From Browser…"].firstMatch
XCTAssertTrue(importItem.waitForExistence(timeout: 5.0), "Expected Import From Browser menu item to exist")
importItem.click()
XCTAssertTrue(
app.staticTexts["Import Browser Data"].waitForExistence(timeout: 5.0),
"Expected the import wizard to open"
)
}
private func waitForCapturedSelection(timeout: TimeInterval) -> [String: Any]? {
let deadline = Date().addingTimeInterval(timeout)
let url = URL(fileURLWithPath: capturePath)
while Date() < deadline {
if let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
return object
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
guard let data = try? Data(contentsOf: url),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return nil
}
return object
}
private func ensureForegroundAfterLaunch(_ app: XCUIApplication, timeout: TimeInterval) -> Bool {
if app.wait(for: .runningForeground, timeout: timeout) {
return true
}
if app.state == .runningBackground {
app.activate()
return app.wait(for: .runningForeground, timeout: 6.0)
}
return false
}
}

View file

@ -294,3 +294,456 @@ final class FeedbackComposerShortcutUITests: XCTestCase {
)
}
}
final class CommandPaletteAllSurfacesUITests: XCTestCase {
private var socketPath = ""
private let hiddenSurfaceToken = "cmux-command-palette-hidden-surface"
private let visibleSurfaceToken = "cmux-command-palette-visible-surface"
override func setUp() {
super.setUp()
continueAfterFailure = false
socketPath = "/tmp/cmux-ui-test-command-palette-\(UUID().uuidString).sock"
try? FileManager.default.removeItem(atPath: socketPath)
}
override func tearDown() {
try? FileManager.default.removeItem(atPath: socketPath)
super.tearDown()
}
func testCmdShiftPBackspaceReturnsToWorkspaceResults() throws {
let app = XCUIApplication()
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
launchAndActivate(app)
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 8.0) {
app.windows.count >= 1
},
"Expected the main window to be visible"
)
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
let mainWindowId = try XCTUnwrap(
socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines)
)
openCommandPaletteCommands(app: app)
_ = try XCTUnwrap(
waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "commands", query: "", timeout: 5.0) { snapshot in
self.commandPaletteResultRows(from: snapshot).contains { row in
let commandId = row["command_id"] as? String ?? ""
return !commandId.hasPrefix("switcher.")
}
}
)
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
let switcherSnapshot = try XCTUnwrap(
waitForCommandPaletteSnapshot(windowId: mainWindowId, mode: "switcher", query: "", timeout: 5.0) { snapshot in
self.commandPaletteResultRows(from: snapshot).contains { row in
let commandId = row["command_id"] as? String ?? ""
return commandId.hasPrefix("switcher.workspace.")
}
}
)
XCTAssertTrue(
commandPaletteResultRows(from: switcherSnapshot).contains { row in
let commandId = row["command_id"] as? String ?? ""
return commandId.hasPrefix("switcher.workspace.")
},
"Expected deleting the command prefix to restore workspace rows. snapshot=\(switcherSnapshot)"
)
let rows = commandPaletteResultRows(from: switcherSnapshot)
let firstRowCommandId = rows.first?["command_id"] as? String ?? ""
XCTAssertTrue(
firstRowCommandId.hasPrefix("switcher.workspace."),
"Expected the first restored row to be a workspace. snapshot=\(switcherSnapshot)"
)
let firstWorkspaceRow = try XCTUnwrap(
rows.first(where: { row in
let commandId = row["command_id"] as? String ?? ""
return commandId.hasPrefix("switcher.workspace.")
}),
"Expected a workspace row in the restored switcher results. snapshot=\(switcherSnapshot)"
)
let workspaceTitle = try XCTUnwrap(
firstWorkspaceRow["title"] as? String,
"Expected the restored workspace row to include a title. snapshot=\(switcherSnapshot)"
)
let workspaceLabel = app.staticTexts[workspaceTitle].firstMatch
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 2.0) {
workspaceLabel.exists && workspaceLabel.isHittable
},
"Expected the restored workspace row to be visibly rendered. title=\(workspaceTitle) snapshot=\(switcherSnapshot)"
)
let staleCommandLabel = app.staticTexts["Close Other Workspaces"].firstMatch
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 2.0) {
!staleCommandLabel.exists || !staleCommandLabel.isHittable
},
"Expected the stale command row to disappear after deleting the command prefix. snapshot=\(switcherSnapshot)"
)
}
func testCmdPSearchCanIncludeSurfacesFromOtherWorkspacesWhenEnabled() throws {
let app = XCUIApplication()
app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_SHOW_SETTINGS"] = "1"
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
launchAndActivate(app)
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 8.0) {
app.windows.count >= 2
},
"Expected the main window and Settings window to be visible"
)
XCTAssertTrue(waitForSocketPong(timeout: 12.0), "Expected control socket at \(socketPath)")
let mainWindowId = try XCTUnwrap(socketCommand("current_window")?.trimmingCharacters(in: .whitespacesAndNewlines))
let secondaryWorkspaceId = try XCTUnwrap(okUUID(from: socketCommand("new_workspace")))
let initialSurfaceId = try XCTUnwrap(waitForSurfaceIDs(minimumCount: 1, timeout: 5.0).first)
let hiddenSurfaceId = try XCTUnwrap(okUUID(from: socketCommand("new_surface --type=terminal")))
XCTAssertEqual(
socketCommand("report_pwd /tmp/\(hiddenSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(hiddenSurfaceId)"),
"OK"
)
XCTAssertEqual(socketCommand("focus_surface \(initialSurfaceId)"), "OK")
XCTAssertEqual(
socketCommand("report_pwd /tmp/\(visibleSurfaceToken) --tab=\(secondaryWorkspaceId) --panel=\(initialSurfaceId)"),
"OK"
)
XCTAssertEqual(socketCommand("select_workspace 0"), "OK")
XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK")
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
openCommandPalette(app: app, query: hiddenSurfaceToken)
let disabledSnapshot = try XCTUnwrap(
waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in
self.commandPaletteResultRows(from: snapshot).isEmpty
}
)
XCTAssertEqual(commandPaletteResultRows(from: disabledSnapshot).count, 0)
dismissCommandPalette(app: app)
focusSettingsWindow(app: app)
let toggle = try requireSearchAllSurfacesToggle(app: app)
if !toggleIsOn(toggle) {
toggle.click()
}
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 3.0) {
toggle.exists && toggleIsOn(toggle)
},
"Expected the all-surfaces search setting to be enabled"
)
XCTAssertEqual(socketCommand("focus_window \(mainWindowId)"), "OK")
openCommandPalette(app: app, query: hiddenSurfaceToken)
let enabledSnapshot = try XCTUnwrap(
waitForCommandPaletteSnapshot(windowId: mainWindowId, query: hiddenSurfaceToken, timeout: 5.0) { snapshot in
self.commandPaletteResultRows(from: snapshot).contains { row in
let commandId = row["command_id"] as? String ?? ""
let trailingLabel = row["trailing_label"] as? String ?? ""
return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal"
}
}
)
XCTAssertTrue(
commandPaletteResultRows(from: enabledSnapshot).contains { row in
let commandId = row["command_id"] as? String ?? ""
let trailingLabel = row["trailing_label"] as? String ?? ""
return commandId.hasPrefix("switcher.surface.") && trailingLabel == "Terminal"
},
"Expected Cmd+P to surface the hidden terminal when all-surfaces search is enabled. snapshot=\(enabledSnapshot)"
)
}
private func launchAndActivate(_ app: XCUIApplication) {
app.launch()
XCTAssertTrue(
sidebarHelpPollUntil(timeout: 4.0) {
guard app.state != .runningForeground else { return true }
app.activate()
return app.state == .runningForeground
},
"App did not reach runningForeground before UI interactions"
)
}
private func openCommandPalette(app: XCUIApplication, query: String) {
let searchField = app.textFields["CommandPaletteSearchField"]
app.typeKey("p", modifierFlags: [.command])
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
searchField.click()
searchField.typeText(query)
}
private func openCommandPaletteCommands(app: XCUIApplication) {
let searchField = app.textFields["CommandPaletteSearchField"]
app.typeKey("p", modifierFlags: [.command, .shift])
XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field")
searchField.click()
}
private func dismissCommandPalette(app: XCUIApplication) {
let searchField = app.textFields["CommandPaletteSearchField"]
for _ in 0..<2 {
app.typeKey(XCUIKeyboardKey.escape.rawValue, modifierFlags: [])
if sidebarHelpPollUntil(timeout: 1.0) { !searchField.exists } {
return
}
}
XCTAssertFalse(searchField.exists, "Expected command palette to dismiss")
}
private func focusSettingsWindow(app: XCUIApplication) {
app.typeKey(",", modifierFlags: [.command])
}
private func requireSearchAllSurfacesToggle(app: XCUIApplication) throws -> XCUIElement {
let toggleId = "CommandPaletteSearchAllSurfacesToggle"
let scrollView = app.scrollViews.firstMatch
let candidates = [
app.switches[toggleId],
app.checkBoxes[toggleId],
app.buttons[toggleId],
app.otherElements[toggleId],
]
for _ in 0..<8 {
if let element = firstExistingElement(candidates: candidates, timeout: 0.4), element.isHittable {
return element
}
if scrollView.exists {
scrollView.swipeUp()
}
}
throw XCTSkip("Could not find the command palette all-surfaces toggle")
}
private func toggleIsOn(_ element: XCUIElement) -> Bool {
let value = String(describing: element.value ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return value == "1" || value == "true" || value == "on"
}
private func firstExistingElement(
candidates: [XCUIElement],
timeout: TimeInterval
) -> XCUIElement? {
var match: XCUIElement?
let found = sidebarHelpPollUntil(timeout: timeout) {
for candidate in candidates where candidate.exists {
match = candidate
return true
}
return false
}
return found ? match : nil
}
private func waitForSocketPong(timeout: TimeInterval) -> Bool {
sidebarHelpPollUntil(timeout: timeout) {
socketCommand("ping") == "PONG"
}
}
private func waitForSurfaceIDs(minimumCount: Int, timeout: TimeInterval) -> [String] {
var ids: [String] = []
let found = sidebarHelpPollUntil(timeout: timeout) {
ids = surfaceIDs()
return ids.count >= minimumCount
}
return found ? ids : surfaceIDs()
}
private func surfaceIDs() -> [String] {
guard let response = socketCommand("list_surfaces"), !response.isEmpty, !response.hasPrefix("No surfaces") else {
return []
}
return response
.split(separator: "\n")
.compactMap { line in
guard let range = line.range(of: ": ") else { return nil }
return String(line[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
private func okUUID(from response: String?) -> String? {
guard let response, response.hasPrefix("OK ") else { return nil }
let value = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
return UUID(uuidString: value) != nil ? value : nil
}
private func socketCommand(_ command: String) -> String? {
ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendLine(command)
}
private func commandPaletteResultRows(from snapshot: [String: Any]) -> [[String: Any]] {
snapshot["results"] as? [[String: Any]] ?? []
}
private func waitForCommandPaletteSnapshot(
windowId: String,
mode: String = "switcher",
query: String,
timeout: TimeInterval,
predicate: (([String: Any]) -> Bool)? = nil
) -> [String: Any]? {
var latest: [String: Any]?
let matched = sidebarHelpPollUntil(timeout: timeout) {
guard let snapshot = commandPaletteSnapshot(windowId: windowId) else { return false }
latest = snapshot
guard (snapshot["visible"] as? Bool) == true else { return false }
guard (snapshot["mode"] as? String) == mode else { return false }
guard (snapshot["query"] as? String) == query else { return false }
return predicate?(snapshot) ?? true
}
return matched ? latest : latest
}
private func commandPaletteSnapshot(windowId: String) -> [String: Any]? {
let envelope = socketJSON(
method: "debug.command_palette.results",
params: [
"window_id": windowId,
"limit": 20,
]
)
guard let ok = envelope?["ok"] as? Bool, ok else { return nil }
return envelope?["result"] as? [String: Any]
}
private func socketJSON(method: String, params: [String: Any]) -> [String: Any]? {
let request: [String: Any] = [
"id": UUID().uuidString,
"method": method,
"params": params,
]
return ControlSocketClient(path: socketPath, responseTimeout: 2.0).sendJSON(request)
}
private final class ControlSocketClient {
private let path: String
private let responseTimeout: TimeInterval
init(path: String, responseTimeout: TimeInterval) {
self.path = path
self.responseTimeout = responseTimeout
}
func sendJSON(_ object: [String: Any]) -> [String: Any]? {
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object),
let line = String(data: data, encoding: .utf8),
let response = sendLine(line),
let responseData = response.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: responseData) as? [String: Any] else {
return nil
}
return parsed
}
func sendLine(_ line: String) -> String? {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { return nil }
defer { close(fd) }
#if os(macOS)
var noSigPipe: Int32 = 1
_ = withUnsafePointer(to: &noSigPipe) { ptr in
setsockopt(
fd,
SOL_SOCKET,
SO_NOSIGPIPE,
ptr,
socklen_t(MemoryLayout<Int32>.size)
)
}
#endif
var addr = sockaddr_un()
memset(&addr, 0, MemoryLayout<sockaddr_un>.size)
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
let bytes = Array(path.utf8CString)
guard bytes.count <= maxLen else { return nil }
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self)
memset(raw, 0, maxLen)
for index in 0..<bytes.count {
raw[index] = bytes[index]
}
}
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
let addrLen = socklen_t(pathOffset + bytes.count)
#if os(macOS)
addr.sun_len = UInt8(min(Int(addrLen), 255))
#endif
let connected = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in
connect(fd, sa, addrLen)
}
}
guard connected == 0 else { return nil }
let payload = line + "\n"
let wrote: Bool = payload.withCString { cString in
var remaining = strlen(cString)
var pointer = UnsafeRawPointer(cString)
while remaining > 0 {
let written = write(fd, pointer, remaining)
if written <= 0 { return false }
remaining -= written
pointer = pointer.advanced(by: written)
}
return true
}
guard wrote else { return nil }
let deadline = Date().addingTimeInterval(responseTimeout)
var buffer = [UInt8](repeating: 0, count: 4096)
var accumulator = ""
while Date() < deadline {
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
let ready = poll(&pollDescriptor, 1, 100)
if ready < 0 {
return nil
}
if ready == 0 {
continue
}
let count = read(fd, &buffer, buffer.count)
if count <= 0 { break }
if let chunk = String(bytes: buffer[0..<count], encoding: .utf8) {
accumulator.append(chunk)
if let newline = accumulator.firstIndex(of: "\n") {
return String(accumulator[..<newline])
}
}
}
return accumulator.isEmpty ? nil : accumulator.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
}

View file

@ -37,6 +37,30 @@ final class SidebarResizeUITests: XCTestCase {
XCTAssertGreaterThanOrEqual(leftDelta, -122, "Resizer moved farther than requested drag-left offset")
}
func testSidebarResizerAllowsSmallerMinimumWidth() {
let app = XCUIApplication()
app.launch()
let window = app.windows.firstMatch
XCTAssertTrue(window.waitForExistence(timeout: 5.0))
let elements = app.descendants(matching: .any)
let resizer = elements["SidebarResizer"]
XCTAssertTrue(resizer.waitForExistence(timeout: 5.0))
XCTAssertTrue(waitForElementHittable(resizer, timeout: 5.0), "Expected sidebar resizer to become hittable")
let start = resizer.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
let farLeft = start.withOffset(CGVector(dx: -max(200, window.frame.width), dy: 0))
start.press(forDuration: 0.1, thenDragTo: farLeft)
let sidebarWidth = max(0, resizer.frame.midX - window.frame.minX)
XCTAssertLessThanOrEqual(
sidebarWidth,
185,
"Expected sidebar minimum width to allow a narrower sidebar than the previous 186 px floor. width=\(sidebarWidth)"
)
}
func testSidebarResizerHasMaximumWidthCap() {
let app = XCUIApplication()
app.launch()

View file

@ -124,6 +124,23 @@ final class UpdatePillUITests: XCTestCase {
assertVisibleSize(noUpdatePill)
}
func testBackgroundDetectedUpdateKeepsOnlyBottomUpdatePill() {
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
systemSettings.terminate()
let app = XCUIApplication()
app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1"
app.launchEnvironment["CMUX_UI_TEST_DETECTED_UPDATE_VERSION"] = "9.9.9"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_STATE"] = "available"
app.launchEnvironment["CMUX_UI_TEST_UPDATE_VERSION"] = "9.9.9"
launchAndActivate(app)
let pill = pillButton(app: app, expectedLabel: "Update Available: 9.9.9")
XCTAssertTrue(pill.waitForExistence(timeout: 6.0))
assertVisibleSize(pill)
XCTAssertFalse(app.otherElements["SidebarUpdateBanner"].exists)
XCTAssertFalse(app.buttons["SidebarUpdateBannerAction"].exists)
}
func testNoSparklePermissionDialogIsShown() {
let systemSettings = XCUIApplication(bundleIdentifier: "com.apple.systempreferences")
systemSettings.terminate()

@ -1 +1 @@
Subproject commit dcfaa081e5b3e0ad62c5c1a5a4d58f4562f6be71
Subproject commit a5f372ecfa5ee3903af6e1faba0eda096b4f5746

View file

@ -0,0 +1,127 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: ./scripts/build-ghostty-cli-helper.sh [--universal | --target <zig-target>] --output <path>
Options:
--universal Build a universal macOS helper (arm64 + x86_64).
--target <triple>
Build a single target, e.g. `aarch64-macos` or `x86_64-macos`.
--output <path> Destination path for the built helper.
EOF
}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
GHOSTTY_DIR="$REPO_ROOT/ghostty"
OUTPUT_PATH=""
TARGET_TRIPLE=""
UNIVERSAL="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--universal)
UNIVERSAL="true"
shift
;;
--target)
TARGET_TRIPLE="${2:-}"
shift 2
;;
--output)
OUTPUT_PATH="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage >&2
exit 1
;;
esac
done
if [[ -z "$OUTPUT_PATH" ]]; then
echo "Missing required --output path" >&2
usage >&2
exit 1
fi
if [[ "$UNIVERSAL" == "true" && -n "$TARGET_TRIPLE" ]]; then
echo "--universal and --target are mutually exclusive" >&2
usage >&2
exit 1
fi
if [[ -n "$TARGET_TRIPLE" ]]; then
case "$TARGET_TRIPLE" in
aarch64-macos|x86_64-macos)
;;
*)
echo "Unsupported --target value: $TARGET_TRIPLE" >&2
exit 1
;;
esac
fi
if ! command -v zig >/dev/null 2>&1; then
echo "error: zig is required to build the Ghostty CLI helper" >&2
exit 1
fi
if [[ ! -f "$GHOSTTY_DIR/build.zig" ]]; then
echo "error: Ghostty submodule is missing at $GHOSTTY_DIR" >&2
exit 1
fi
build_helper() {
local prefix="$1"
local target="${2:-}"
local args=(
zig build
cli-helper
-Dapp-runtime=none
-Demit-macos-app=false
-Demit-xcframework=false
-Doptimize=ReleaseFast
--prefix
"$prefix"
)
if [[ -n "$target" ]]; then
args+=("-Dtarget=$target")
fi
(
cd "$GHOSTTY_DIR"
"${args[@]}"
)
}
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/cmux-ghostty-helper.XXXXXX")"
trap 'rm -rf "$TMP_DIR"' EXIT
mkdir -p "$(dirname "$OUTPUT_PATH")"
if [[ "$UNIVERSAL" == "true" ]]; then
ARM64_PREFIX="$TMP_DIR/arm64"
X86_PREFIX="$TMP_DIR/x86_64"
build_helper "$ARM64_PREFIX" "aarch64-macos"
build_helper "$X86_PREFIX" "x86_64-macos"
/usr/bin/lipo -create \
"$ARM64_PREFIX/bin/ghostty" \
"$X86_PREFIX/bin/ghostty" \
-output "$OUTPUT_PATH"
else
SINGLE_PREFIX="$TMP_DIR/single"
build_helper "$SINGLE_PREFIX" "$TARGET_TRIPLE"
install -m 755 "$SINGLE_PREFIX/bin/ghostty" "$OUTPUT_PATH"
fi
chmod +x "$OUTPUT_PATH"

View file

@ -74,6 +74,12 @@ rm -rf build/
xcodebuild -scheme cmux -configuration Release -derivedDataPath build CODE_SIGNING_ALLOWED=NO build 2>&1 | tail -5
echo "Build succeeded"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ ! -x "$HELPER_PATH" ]; then
echo "Ghostty theme picker helper not found at $HELPER_PATH" >&2
exit 1
fi
# --- Inject Sparkle keys ---
echo "Injecting Sparkle keys..."
SPARKLE_PUBLIC_KEY_DERIVED=$(swift scripts/derive_sparkle_public_key.swift "$SPARKLE_PRIVATE_KEY")
@ -90,6 +96,9 @@ CLI_PATH="$APP_PATH/Contents/Resources/bin/cmux"
if [ -f "$CLI_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$CLI_PATH"
fi
if [ -f "$HELPER_PATH" ]; then
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" "$HELPER_PATH"
fi
/usr/bin/codesign --force --options runtime --timestamp --sign "$SIGN_HASH" --entitlements "$ENTITLEMENTS" --deep "$APP_PATH"
/usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH"
echo "Codesign verified"

View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
SOURCE_PACKAGES_DIR="${CMUX_SOURCE_PACKAGES_DIR:-$PWD/.ci-source-packages}"
DERIVED_DATA_PATH="${CMUX_DERIVED_DATA_PATH:-$PWD/.ci-bundled-ghostty-helper}"
CONFIGURATION="${CMUX_CONFIGURATION:-Debug}"
case "$CONFIGURATION" in
Debug)
APP_NAME="cmux DEV.app"
;;
Release)
APP_NAME="cmux.app"
;;
*)
echo "FAIL: unsupported configuration $CONFIGURATION" >&2
exit 1
;;
esac
mkdir -p "$SOURCE_PACKAGES_DIR"
rm -rf "$DERIVED_DATA_PATH"
xcodebuild \
-project GhosttyTabs.xcodeproj \
-scheme cmux \
-configuration "$CONFIGURATION" \
-clonedSourcePackagesDirPath "$SOURCE_PACKAGES_DIR" \
-disableAutomaticPackageResolution \
-derivedDataPath "$DERIVED_DATA_PATH" \
-destination "platform=macOS" \
build
APP_PATH="$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION/$APP_NAME"
HELPER_PATH="$APP_PATH/Contents/Resources/bin/ghostty"
if [ ! -x "$HELPER_PATH" ]; then
echo "FAIL: bundled Ghostty theme picker helper missing at $HELPER_PATH" >&2
exit 1
fi
echo "PASS: bundled Ghostty theme picker helper present at $HELPER_PATH"

View file

@ -139,10 +139,22 @@ def test_live_socket_injects_supported_hooks(failures: list[str]) -> None:
settings = parse_settings_arg(real_argv)
hooks = settings.get("hooks", {})
expect(set(hooks.keys()) == {"SessionStart", "Stop", "Notification"}, f"unexpected hook keys: {hooks.keys()}", failures)
serialized = json.dumps(settings, sort_keys=True)
expect("UserPromptSubmit" not in serialized, "UserPromptSubmit hook should not be injected", failures)
expect("prompt-submit" not in serialized, "prompt-submit subcommand should not be injected", failures)
expected_hooks = {"SessionStart", "Stop", "SessionEnd", "Notification", "UserPromptSubmit", "PreToolUse"}
expect(set(hooks.keys()) == expected_hooks, f"unexpected hook keys: {hooks.keys()}, expected {expected_hooks}", failures)
# PreToolUse should be async to avoid blocking tool execution
pre_tool_use_hooks = hooks.get("PreToolUse", [{}])[0].get("hooks", [{}])
expect(
any(h.get("async") is True for h in pre_tool_use_hooks),
f"PreToolUse hook should have async:true, got {pre_tool_use_hooks}",
failures,
)
# SessionEnd should have a short timeout (session is exiting)
session_end_hooks = hooks.get("SessionEnd", [{}])[0].get("hooks", [{}])
expect(
any(h.get("timeout", 999) <= 2 for h in session_end_hooks),
f"SessionEnd hook should have short timeout, got {session_end_hooks}",
failures,
)
def test_missing_socket_skips_hook_injection(failures: list[str]) -> None:

View file

@ -10,6 +10,9 @@ Validates that shell integration:
4) recovers when a gh probe wedges longer than the async timeout
5) keeps polling in bash after prompt-render helper commands run
6) tears down the timed-out gh probe instead of leaking it in the background
7) falls back to explicit branch lookup when implicit gh branch resolution fails
8) does not clear an existing PR badge on the first prompt while establishing
the HEAD baseline
"""
from __future__ import annotations
@ -77,6 +80,11 @@ def _git_stub() -> str:
exit 0
fi
if [ "$1" = "remote" ] && [ "$2" = "get-url" ] && [ "$3" = "origin" ]; then
printf 'https://github.com/manaflow-ai/cmux.git\\n'
exit 0
fi
if [ "$1" = "status" ] && [ "$2" = "--porcelain" ] && [ "$3" = "-uno" ]; then
exit 0
fi
@ -111,6 +119,17 @@ def _gh_stub() -> str:
exit 9
fi
requested_branch=""
if [ $# -ge 3 ]; then
case "$3" in
--*)
;;
*)
requested_branch="$3"
;;
esac
fi
branch=""
if [ -f "$head_file" ]; then
head_line="$(cat "$head_file")"
@ -125,6 +144,9 @@ def _gh_stub() -> str:
prompt_helper_idle)
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
;;
initial_prompt_preserves_pr_badge)
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
;;
transient_same_context)
if [ "$count" -eq 1 ]; then
printf 'rate limit exceeded\\n' >&2
@ -154,6 +176,18 @@ def _gh_stub() -> str:
fi
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
;;
explicit_branch_fallback)
if [ -z "$requested_branch" ]; then
printf 'no pull requests found for branch "%s"\\n' "$branch" >&2
exit 1
fi
if [ "$requested_branch" = "$branch" ]; then
printf '1138\\tOPEN\\thttps://github.com/manaflow-ai/cmux/pull/1138\\n'
exit 0
fi
printf 'unexpected branch lookup: %s\\n' "$requested_branch" >&2
exit 8
;;
*)
printf 'unknown scenario: %s\\n' "$scenario" >&2
exit 2
@ -198,6 +232,20 @@ def _shell_command(kind: str, scenario: str) -> str:
'sleep 4\n'
'_cmux_cleanup\n'
),
"explicit_branch_fallback": (
'cd "$CMUX_TEST_REPO"\n'
'_CMUX_PR_POLL_INTERVAL=10\n'
'_cmux_prompt_entry\n'
'sleep 2\n'
'_cmux_cleanup\n'
),
"initial_prompt_preserves_pr_badge": (
'cd "$CMUX_TEST_REPO"\n'
'_CMUX_PR_POLL_INTERVAL=10\n'
'_cmux_prompt_entry\n'
'sleep 2\n'
'_cmux_cleanup\n'
),
}[scenario]
if kind == "zsh":
@ -344,6 +392,27 @@ def _run_case(base: Path, *, shell: str, shell_args: list[str], script: Path, sc
return (1, f"{shell}/{scenario}: timed-out gh probe still running as pid {gh_pid}")
return (0, f"{shell}/{scenario}: ok")
if scenario == "explicit_branch_fallback":
if _report_line(1138) not in send_lines:
return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines))
if not any(line.startswith("pr view feature/issue-1138 ") for line in gh_args_lines):
return (
1,
f"{shell}/{scenario}: expected explicit branch fallback\n" + "\n".join(gh_args_lines),
)
return (0, f"{shell}/{scenario}: ok")
if scenario == "initial_prompt_preserves_pr_badge":
if _report_line(1138) not in send_lines:
return (1, f"{shell}/{scenario}: missing report_pr payload\n" + "\n".join(send_lines))
if any(line.startswith("clear_pr ") for line in send_lines):
return (
1,
f"{shell}/{scenario}: initial prompt should not clear an existing PR badge\n"
+ "\n".join(send_lines),
)
return (0, f"{shell}/{scenario}: ok")
return (1, f"{shell}/{scenario}: unhandled scenario")
@ -358,6 +427,8 @@ def main() -> int:
"transient_same_context",
"branch_switch_clear",
"timeout_recovery",
"explicit_branch_fallback",
"initial_prompt_preserves_pr_badge",
]
base = Path("/tmp") / f"cmux_issue_1138_pr_poll_{os.getpid()}"

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit 73c1ef2df9a6c8a2837212ecce900794d0f21826
Subproject commit 02fa188ccd244b1e6efc037e4ed631e966144795

View file

@ -158,7 +158,7 @@ printf '\\e]99;i=1;e=1;d=1;p=body:All tests passed\\e\\\\'`}</CodeBlock>
[ -S /tmp/cmux.sock ] || exit 0
EVENT=$(cat)
EVENT_TYPE=$(echo "$EVENT" | jq -r '.event // "unknown"')
EVENT_TYPE=$(echo "$EVENT" | jq -r '.hook_event_name // "unknown"')
TOOL=$(echo "$EVENT" | jq -r '.tool_name // ""')
case "$EVENT_TYPE" in
@ -174,11 +174,26 @@ esac`}</CodeBlock>
<h3>{t("configureClaude")}</h3>
<CodeBlock title="~/.claude/settings.json" lang="json">{`{
"hooks": {
"Stop": ["~/.claude/hooks/cmux-notify.sh"],
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/cmux-notify.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Task",
"hooks": ["~/.claude/hooks/cmux-notify.sh"]
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/cmux-notify.sh"
}
]
}
]
}