Merge origin/main into fix-dragdrop-overlay

This commit is contained in:
Lawrence Chen 2026-02-20 18:29:36 -08:00
commit 23979d8c02
29 changed files with 5475 additions and 292 deletions

View file

@ -860,6 +860,19 @@ struct CMUXCLI {
let payload = try client.sendV2(method: "workspace.select", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "rename-workspace", "rename-window":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0)
let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else {
throw CLIError(message: "\(command) requires a title")
}
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let params: [String: Any] = ["title": title, "workspace_id": wsId]
let payload = try client.sendV2(method: "workspace.rename", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "current-workspace":
let response = try client.send(command: "current_workspace")
if jsonOutput {
@ -868,6 +881,43 @@ struct CMUXCLI {
print(response)
}
case "read-screen":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
let (linesArg, rem2) = parseOption(rem1, name: "--lines")
let trailing = rem2.filter { $0 != "--scrollback" }
if !trailing.isEmpty {
throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))")
}
let workspaceArg = wsArg ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = sfArg ?? (wsArg == nil && windowId == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let includeScrollback = rem2.contains("--scrollback")
if includeScrollback {
params["scrollback"] = true
}
if let linesArg {
guard let lineCount = Int(linesArg), lineCount > 0 else {
throw CLIError(message: "--lines must be greater than 0")
}
params["lines"] = lineCount
params["scrollback"] = true
}
let payload = try client.sendV2(method: "surface.read_text", params: params)
if jsonOutput {
print(jsonString(payload))
} else {
print((payload["text"] as? String) ?? "")
}
case "send":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
@ -988,6 +1038,38 @@ struct CMUXCLI {
let response = try client.send(command: "simulate_app_active")
print(response)
case "capture-pane",
"resize-pane",
"pipe-pane",
"wait-for",
"swap-pane",
"break-pane",
"join-pane",
"last-window",
"last-pane",
"next-window",
"previous-window",
"find-window",
"clear-history",
"set-hook",
"popup",
"bind-key",
"unbind-key",
"copy-mode",
"set-buffer",
"paste-buffer",
"list-buffers",
"respawn-pane",
"display-message":
try runTmuxCompatCommand(
command: command,
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowId
)
case "help":
print(usage())
@ -2803,6 +2885,70 @@ struct CMUXCLI {
cmux select-workspace --workspace workspace:2
cmux select-workspace --workspace 0
"""
case "rename-workspace", "rename-window":
return """
Usage: cmux rename-workspace [--workspace <id|ref>] [--] <title>
Rename a workspace. Defaults to the current workspace.
tmux-compatible alias: rename-window
Flags:
--workspace <id|ref> Workspace to rename (default: current workspace)
Example:
cmux rename-workspace "backend logs"
cmux rename-window --workspace workspace:2 "agent run"
"""
case "capture-pane":
return """
Usage: cmux capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
tmux-compatible alias for reading terminal text from a pane.
Example:
cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200
"""
case "resize-pane":
return """
Usage: cmux resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>]
tmux-compatible pane resize command.
Note: currently returns not_supported until programmable divider resize is implemented.
"""
case "pipe-pane":
return """
Usage: cmux pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>]
Capture pane text and pipe it to a shell command via stdin.
"""
case "wait-for":
return """
Usage: cmux wait-for [-S|--signal] <name> [--timeout <seconds>]
Wait for or signal a named synchronization token.
"""
case "swap-pane", "break-pane", "join-pane", "next-window", "previous-window", "last-window", "last-pane", "find-window", "clear-history", "set-hook", "popup", "bind-key", "unbind-key", "copy-mode", "set-buffer", "paste-buffer", "list-buffers", "respawn-pane", "display-message":
return """
Usage: cmux \(command) --help
tmux compatibility command. See `cmux --help` for exact syntax.
"""
case "read-screen":
return """
Usage: cmux read-screen [flags]
Read terminal text from a surface as plain text.
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
--scrollback Include scrollback (not just visible viewport)
--lines <n> Limit to the last n lines (implies --scrollback)
Example:
cmux read-screen
cmux read-screen --surface surface:2 --scrollback --lines 200
"""
case "send":
return """
Usage: cmux send [flags] [--] <text>
@ -3025,6 +3171,438 @@ struct CMUXCLI {
return output
}
private struct TmuxCompatStore: Codable {
var buffers: [String: String] = [:]
var hooks: [String: String] = [:]
}
private func tmuxCompatStoreURL() -> URL {
let root = NSString(string: "~/.cmuxterm").expandingTildeInPath
return URL(fileURLWithPath: root).appendingPathComponent("tmux-compat-store.json")
}
private func loadTmuxCompatStore() -> TmuxCompatStore {
let url = tmuxCompatStoreURL()
guard let data = try? Data(contentsOf: url),
let decoded = try? JSONDecoder().decode(TmuxCompatStore.self, from: data) else {
return TmuxCompatStore()
}
return decoded
}
private func saveTmuxCompatStore(_ store: TmuxCompatStore) throws {
let url = tmuxCompatStoreURL()
let parent = url.deletingLastPathComponent()
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true, attributes: nil)
let data = try JSONEncoder().encode(store)
try data.write(to: url, options: .atomic)
}
private func runShellCommand(_ command: String, stdinText: String) throws -> (status: Int32, stdout: String, stderr: String) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-lc", command]
let stdinPipe = Pipe()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
process.standardInput = stdinPipe
process.standardOutput = stdoutPipe
process.standardError = stderrPipe
try process.run()
if let data = stdinText.data(using: .utf8) {
stdinPipe.fileHandleForWriting.write(data)
}
stdinPipe.fileHandleForWriting.closeFile()
process.waitUntilExit()
let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
return (process.terminationStatus, stdout, stderr)
}
private func tmuxWaitForSignalURL(name: String) -> URL {
let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "._-"))
let sanitized = name.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }
return URL(fileURLWithPath: "/tmp/cmux-wait-for-\(String(sanitized)).sig")
}
private func runTmuxCompatCommand(
command: String,
commandArgs: [String],
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
switch command {
case "capture-pane":
let (wsArg, rem0) = parseOption(commandArgs, name: "--workspace")
let (sfArg, rem1) = parseOption(rem0, name: "--surface")
let (linesArg, rem2) = parseOption(rem1, name: "--lines")
let workspaceArg = wsArg ?? (windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = sfArg ?? (wsArg == nil && windowOverride == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let sfId { params["surface_id"] = sfId }
let includeScrollback = rem2.contains("--scrollback")
if includeScrollback {
params["scrollback"] = true
}
if let linesArg {
guard let lineCount = Int(linesArg), lineCount > 0 else {
throw CLIError(message: "--lines must be greater than 0")
}
params["lines"] = lineCount
params["scrollback"] = true
}
let payload = try client.sendV2(method: "surface.read_text", params: params)
if jsonOutput {
print(jsonString(payload))
} else {
print((payload["text"] as? String) ?? "")
}
case "resize-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let paneArg = optionValue(commandArgs, name: "--pane")
let amountArg = optionValue(commandArgs, name: "--amount")
let amount = Int(amountArg ?? "1") ?? 1
if amount <= 0 {
throw CLIError(message: "--amount must be greater than 0")
}
let direction: String = {
if commandArgs.contains("-L") { return "left" }
if commandArgs.contains("-R") { return "right" }
if commandArgs.contains("-U") { return "up" }
if commandArgs.contains("-D") { return "down" }
return "right"
}()
var params: [String: Any] = ["direction": direction, "amount": amount]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let paneId { params["pane_id"] = paneId }
let payload = try client.sendV2(method: "pane.resize", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"]))
case "pipe-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
let (cmdOpt, rem0) = parseOption(commandArgs, name: "--command")
let commandText: String = {
if let cmdOpt { return cmdOpt }
let trimmed = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed
}()
guard !commandText.isEmpty else {
throw CLIError(message: "pipe-pane requires --command <shell-command>")
}
var params: [String: Any] = ["scrollback": true]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.read_text", params: params)
let text = (payload["text"] as? String) ?? ""
let shell = try runShellCommand(commandText, stdinText: text)
if shell.status != 0 {
throw CLIError(message: "pipe-pane command failed (\(shell.status)): \(shell.stderr)")
}
if jsonOutput {
print(jsonString([
"ok": true,
"status": shell.status,
"stdout": shell.stdout,
"stderr": shell.stderr
]))
} else {
if !shell.stdout.isEmpty {
print(shell.stdout, terminator: "")
}
print("OK")
}
case "wait-for":
let signal = commandArgs.contains("-S") || commandArgs.contains("--signal")
let timeoutRaw = optionValue(commandArgs, name: "--timeout")
let timeout = timeoutRaw.flatMap { Double($0) } ?? 30.0
let name = commandArgs.first(where: { !$0.hasPrefix("-") }) ?? ""
guard !name.isEmpty else {
throw CLIError(message: "wait-for requires a name")
}
let signalURL = tmuxWaitForSignalURL(name: name)
if signal {
FileManager.default.createFile(atPath: signalURL.path, contents: Data())
print("OK")
return
}
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
if FileManager.default.fileExists(atPath: signalURL.path) {
try? FileManager.default.removeItem(at: signalURL)
print("OK")
return
}
Thread.sleep(forTimeInterval: 0.05)
}
throw CLIError(message: "wait-for timed out waiting for '\(name)'")
case "swap-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
guard let sourcePaneRaw = optionValue(commandArgs, name: "--pane") else {
throw CLIError(message: "swap-pane requires --pane")
}
guard let targetPaneRaw = optionValue(commandArgs, name: "--target-pane") else {
throw CLIError(message: "swap-pane requires --target-pane")
}
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sourcePane = try normalizePaneHandle(sourcePaneRaw, client: client, workspaceHandle: wsId)
let targetPane = try normalizePaneHandle(targetPaneRaw, client: client, workspaceHandle: wsId)
if let sourcePane { params["pane_id"] = sourcePane }
if let targetPane { params["target_pane_id"] = targetPane }
let payload = try client.sendV2(method: "pane.swap", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "break-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let paneArg = optionValue(commandArgs, name: "--pane")
let surfaceArg = optionValue(commandArgs, name: "--surface")
var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let paneId = try normalizePaneHandle(paneArg, client: client, workspaceHandle: wsId)
if let paneId { params["pane_id"] = paneId }
let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let surfaceId { params["surface_id"] = surfaceId }
let payload = try client.sendV2(method: "pane.break", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "join-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let sourcePaneArg = optionValue(commandArgs, name: "--pane")
let surfaceArg = optionValue(commandArgs, name: "--surface")
guard let targetPaneArg = optionValue(commandArgs, name: "--target-pane") else {
throw CLIError(message: "join-pane requires --target-pane")
}
var params: [String: Any] = ["focus": !commandArgs.contains("--no-focus")]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sourcePaneId = try normalizePaneHandle(sourcePaneArg, client: client, workspaceHandle: wsId)
if let sourcePaneId { params["pane_id"] = sourcePaneId }
let targetPaneId = try normalizePaneHandle(targetPaneArg, client: client, workspaceHandle: wsId)
if let targetPaneId { params["target_pane_id"] = targetPaneId }
let surfaceId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId)
if let surfaceId { params["surface_id"] = surfaceId }
let payload = try client.sendV2(method: "pane.join", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "last-window":
let payload = try client.sendV2(method: "workspace.last")
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "next-window":
let payload = try client.sendV2(method: "workspace.next")
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "previous-window":
let payload = try client.sendV2(method: "workspace.previous")
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["workspace"]))
case "last-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let payload = try client.sendV2(method: "pane.last", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat, kinds: ["pane"]))
case "find-window":
let includeContent = commandArgs.contains("--content")
let shouldSelect = commandArgs.contains("--select")
let query = commandArgs
.filter { !$0.hasPrefix("-") }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
let listPayload = try client.sendV2(method: "workspace.list")
let workspaces = listPayload["workspaces"] as? [[String: Any]] ?? []
var matches: [[String: Any]] = []
for ws in workspaces {
let title = (ws["title"] as? String) ?? ""
let titleMatch = query.isEmpty || title.localizedCaseInsensitiveContains(query)
var contentMatch = false
if includeContent && !query.isEmpty, let wsId = ws["id"] as? String {
let textPayload = try? client.sendV2(method: "surface.read_text", params: ["workspace_id": wsId])
let text = (textPayload?["text"] as? String) ?? ""
contentMatch = text.localizedCaseInsensitiveContains(query)
}
if titleMatch || contentMatch {
matches.append(ws)
}
}
if shouldSelect, let first = matches.first, let wsId = first["id"] as? String {
_ = try client.sendV2(method: "workspace.select", params: ["workspace_id": wsId])
}
if jsonOutput {
let formatted = formatIDs(["matches": matches], mode: idFormat) as? [String: Any]
print(jsonString(["matches": formatted?["matches"] ?? []]))
} else if matches.isEmpty {
print("No matches")
} else {
for item in matches {
let handle = textHandle(item, idFormat: idFormat)
let title = (item["title"] as? String) ?? ""
print("\(handle) \"\(title)\"")
}
}
case "clear-history":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
var params: [String: Any] = [:]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.clear_history", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: v2OKSummary(payload, idFormat: idFormat))
case "set-hook":
var store = loadTmuxCompatStore()
if commandArgs.contains("--list") {
if jsonOutput {
print(jsonString(["hooks": store.hooks]))
} else if store.hooks.isEmpty {
print("No hooks configured")
} else {
for (event, hookCmd) in store.hooks.sorted(by: { $0.key < $1.key }) {
print("\(event) -> \(hookCmd)")
}
}
return
}
if commandArgs.contains("--unset") {
guard let event = commandArgs.last else {
throw CLIError(message: "set-hook --unset requires an event name")
}
store.hooks.removeValue(forKey: event)
try saveTmuxCompatStore(store)
print("OK")
return
}
guard let event = commandArgs.first(where: { !$0.hasPrefix("-") }) else {
throw CLIError(message: "set-hook requires <event> <command>")
}
let commandText = commandArgs.drop(while: { $0 != event }).dropFirst().joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !commandText.isEmpty else {
throw CLIError(message: "set-hook requires <event> <command>")
}
store.hooks[event] = commandText
try saveTmuxCompatStore(store)
print("OK")
case "popup":
throw CLIError(message: "popup is not supported yet in cmux CLI parity mode")
case "bind-key", "unbind-key", "copy-mode":
throw CLIError(message: "\(command) is not supported yet in cmux CLI parity mode")
case "set-buffer":
let (nameArg, rem0) = parseOption(commandArgs, name: "--name")
let name = (nameArg?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? nameArg! : "default"
let content = rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else {
throw CLIError(message: "set-buffer requires text")
}
var store = loadTmuxCompatStore()
store.buffers[name] = content
try saveTmuxCompatStore(store)
print("OK")
case "list-buffers":
let store = loadTmuxCompatStore()
if jsonOutput {
let payload = store.buffers.map { key, value in ["name": key, "size": value.count] }
print(jsonString(["buffers": payload.sorted { ($0["name"] as? String ?? "") < ($1["name"] as? String ?? "") }]))
} else if store.buffers.isEmpty {
print("No buffers")
} else {
for key in store.buffers.keys.sorted() {
let size = store.buffers[key]?.count ?? 0
print("\(key)\t\(size)")
}
}
case "paste-buffer":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
let name = optionValue(commandArgs, name: "--name") ?? "default"
let store = loadTmuxCompatStore()
guard let buffer = store.buffers[name] else {
throw CLIError(message: "Buffer not found: \(name)")
}
var params: [String: Any] = ["text": buffer]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "respawn-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowOverride)
let surfaceArg = optionValue(commandArgs, name: "--surface")
let (commandOpt, rem0) = parseOption(commandArgs, name: "--command")
let commandText = (commandOpt ?? rem0.dropFirst(rem0.first == "--" ? 1 : 0).joined(separator: " ")).trimmingCharacters(in: .whitespacesAndNewlines)
let finalCommand = commandText.isEmpty ? "exec ${SHELL:-/bin/zsh} -l" : commandText
var params: [String: Any] = ["text": finalCommand + "\n"]
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, allowCurrent: true)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, allowFocused: true)
if let sfId { params["surface_id"] = sfId }
let payload = try client.sendV2(method: "surface.send_text", params: params)
printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: "OK")
case "display-message":
let printOnly = commandArgs.contains("-p") || commandArgs.contains("--print")
let message = commandArgs
.filter { !$0.hasPrefix("-") }
.joined(separator: " ")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else {
throw CLIError(message: "display-message requires text")
}
if printOnly {
print(message)
return
}
let payload = try client.sendV2(method: "notification.create", params: ["title": "cmux", "body": message])
if jsonOutput {
print(jsonString(payload))
} else {
print(message)
}
default:
throw CLIError(message: "Unsupported tmux compatibility command: \(command)")
}
}
private func runClaudeHook(commandArgs: [String], client: SocketClient) throws {
let subcommand = commandArgs.first?.lowercased() ?? "help"
let hookArgs = Array(commandArgs.dropFirst())
@ -3462,7 +4040,10 @@ struct CMUXCLI {
focus-panel --panel <id|ref> [--workspace <id|ref>]
close-workspace --workspace <id|ref>
select-workspace --workspace <id|ref>
rename-workspace [--workspace <id|ref>] <title>
rename-window [--workspace <id|ref>] <title>
current-workspace
read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
send [--workspace <id|ref>] [--surface <id|ref>] <text>
send-key [--workspace <id|ref>] [--surface <id|ref>] <key>
send-panel --panel <id|ref> [--workspace <id|ref>] <text>
@ -3474,6 +4055,27 @@ struct CMUXCLI {
set-app-focus <active|inactive|clear>
simulate-app-active
# tmux compatibility commands
capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]
resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>]
pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>]
wait-for [-S|--signal] <name> [--timeout <seconds>]
swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>]
break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus]
next-window | previous-window | last-window
last-pane [--workspace <id|ref>]
find-window [--content] [--select] <query>
clear-history [--workspace <id|ref>] [--surface <id|ref>]
set-hook [--list] [--unset <event>] | <event> <command>
popup
bind-key | unbind-key | copy-mode
set-buffer [--name <name>] <text>
list-buffers
paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>]
respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>]
display-message [-p|--print] <text>
browser [--surface <id|ref|index> | <surface>] <subcommand> ...
browser open [url] (create browser split in caller's workspace; if surface supplied, behaves like navigate)
browser open-split [url]

View file

@ -15,6 +15,7 @@
A5001004 /* GhosttyConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001014 /* GhosttyConfig.swift */; };
A5001005 /* GhosttyTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001015 /* GhosttyTerminalView.swift */; };
A5001532 /* TerminalWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001531 /* TerminalWindowPortal.swift */; };
A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; };
A5001540 /* PortScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001541 /* PortScanner.swift */; };
A5001006 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5001016 /* GhosttyKit.xcframework */; };
A5001007 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001019 /* TerminalController.swift */; };
@ -137,6 +138,7 @@
A5001014 /* GhosttyConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyConfig.swift; sourceTree = "<group>"; };
A5001015 /* GhosttyTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyTerminalView.swift; sourceTree = "<group>"; };
A5001531 /* TerminalWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalWindowPortal.swift; sourceTree = "<group>"; };
A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = "<group>"; };
A5001541 /* PortScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PortScanner.swift; sourceTree = "<group>"; };
A5001016 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5001017 /* ghostty.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ghostty.h; sourceTree = "<group>"; };
@ -313,6 +315,7 @@
A5001014 /* GhosttyConfig.swift */,
A5001015 /* GhosttyTerminalView.swift */,
A5001531 /* TerminalWindowPortal.swift */,
A5001533 /* BrowserWindowPortal.swift */,
A5001019 /* TerminalController.swift */,
A5001541 /* PortScanner.swift */,
A5001225 /* SocketControlSettings.swift */,
@ -541,6 +544,7 @@
A5001004 /* GhosttyConfig.swift in Sources */,
A5001005 /* GhosttyTerminalView.swift in Sources */,
A5001532 /* TerminalWindowPortal.swift in Sources */,
A5001534 /* BrowserWindowPortal.swift in Sources */,
A5001007 /* TerminalController.swift in Sources */,
A5001540 /* PortScanner.swift in Sources */,
A5001226 /* SocketControlSettings.swift in Sources */,

View file

@ -136,6 +136,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
### Browser
Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`.
| Shortcut | Action |
|----------|--------|
| ⌘ ⇧ L | Open browser in split |
@ -143,7 +145,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
| ⌘ [ | Back |
| ⌘ ] | Forward |
| ⌘ R | Reload page |
| ⌥ ⌘ I | Open Developer Tools |
| ⌥ ⌘ I | Toggle Developer Tools (Safari default) |
| ⌥ ⌘ C | Show JavaScript Console (Safari default) |
### Notifications
@ -186,6 +189,14 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta
cmux NIGHTLY is a separate app with its own bundle ID, so it runs alongside the stable version. Built automatically from the latest `main` commit and auto-updates via its own Sparkle feed.
## Community
- [Discord](https://discord.com/invite/QRxkhZgY)
- [GitHub](https://github.com/manaflow-ai/cmux)
- [X / Twitter](https://twitter.com/manaflowai)
- [YouTube](https://www.youtube.com/channel/UCAa89_j-TWkrXfk9A3CbASw)
- [LinkedIn](https://www.linkedin.com/company/manaflow-ai/)
## License
This project is licensed under the GNU Affero General Public License v3.0 or later (`AGPL-3.0-or-later`).

View file

@ -92,6 +92,11 @@
</array>
</dict>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>SUFeedURL</key>
<string>https://github.com/manaflow-ai/cmux/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>

View file

@ -96,6 +96,13 @@ func browserOmnibarSelectionDeltaForArrowNavigation(
}
}
func browserOmnibarShouldSubmitOnReturn(flags: NSEvent.ModifierFlags) -> Bool {
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
return normalizedFlags == [] || normalizedFlags == [.shift]
}
enum BrowserZoomShortcutAction: Equatable {
case zoomIn
case zoomOut
@ -187,6 +194,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var workspaceObserver: NSObjectProtocol?
private var windowKeyObserver: NSObjectProtocol?
private var shortcutMonitor: Any?
private var shortcutDefaultsObserver: NSObjectProtocol?
private var ghosttyConfigObserver: NSObjectProtocol?
private var ghosttyGotoSplitLeftShortcut: StoredShortcut?
private var ghosttyGotoSplitRightShortcut: StoredShortcut?
@ -336,6 +344,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
installWindowKeyEquivalentSwizzle()
installBrowserAddressBarFocusObservers()
installShortcutMonitor()
installShortcutDefaultsObserver()
NSApp.servicesProvider = self
#if DEBUG
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
@ -1442,6 +1451,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
let frType = NSApp.keyWindow?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog("monitor.keyDown: \(NSWindow.keyDescription(event)) fr=\(frType) addrBarId=\(self.browserAddressBarFocusedPanelId?.uuidString.prefix(8) ?? "nil")")
if let probeKind = self.developerToolsShortcutProbeKind(event: event) {
self.logDeveloperToolsShortcutSnapshot(phase: "monitor.pre.\(probeKind)", event: event)
}
#endif
if self.handleCustomShortcut(event: event) {
#if DEBUG
@ -1460,6 +1472,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
private func installShortcutDefaultsObserver() {
guard shortcutDefaultsObserver == nil else { return }
shortcutDefaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.refreshSplitButtonTooltipsAcrossWorkspaces()
}
}
private func refreshSplitButtonTooltipsAcrossWorkspaces() {
var refreshedManagers: Set<ObjectIdentifier> = []
if let manager = tabManager {
manager.refreshSplitButtonTooltips()
refreshedManagers.insert(ObjectIdentifier(manager))
}
for context in mainWindowContexts.values {
let manager = context.tabManager
let identifier = ObjectIdentifier(manager)
guard refreshedManagers.insert(identifier).inserted else { continue }
manager.refreshSplitButtonTooltips()
}
}
private func installGhosttyConfigObserver() {
guard ghosttyConfigObserver == nil else { return }
ghosttyConfigObserver = NotificationCenter.default.addObserver(
@ -1861,6 +1898,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserRight)) {
_ = performBrowserSplitShortcut(direction: .right)
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .splitBrowserDown)) {
_ = performBrowserSplitShortcut(direction: .down)
return true
}
// Surface navigation (legacy Ctrl+Tab support)
if matchTabShortcut(event: event, shortcut: StoredShortcut(key: "\t", command: false, shift: false, option: false, control: true)) {
tabManager?.selectNextSurface()
@ -1885,6 +1932,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
// Safari defaults:
// - Option+Command+I => Show/Toggle Web Inspector
// - Option+Command+C => Show JavaScript Console
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "toggle.pre", event: event)
#endif
let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "toggle.post", event: event, didHandle: didHandle)
DispatchQueue.main.async { [weak self] in
self?.logDeveloperToolsShortcutSnapshot(phase: "toggle.tick", didHandle: didHandle)
}
#endif
if !didHandle { NSSound.beep() }
return true
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "console.pre", event: event)
#endif
let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false
#if DEBUG
logDeveloperToolsShortcutSnapshot(phase: "console.post", event: event, didHandle: didHandle)
DispatchQueue.main.async { [weak self] in
self?.logDeveloperToolsShortcutSnapshot(phase: "console.tick", didHandle: didHandle)
}
#endif
if !didHandle { NSSound.beep() }
return true
}
// Focus browser address bar: Cmd+L
if flags == [.command] && chars == "l" {
if let focusedPanel = tabManager?.focusedBrowserPanel {
@ -2032,15 +2112,174 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
private func isLikelyWebInspectorResponder(_ responder: NSResponder?) -> Bool {
guard let responder else { return false }
let responderType = String(describing: type(of: responder))
if responderType.contains("WKInspector") {
return true
}
guard let view = responder as? NSView else { return false }
var node: NSView? = view
var hops = 0
while let current = node, hops < 64 {
if String(describing: type(of: current)).contains("WKInspector") {
return true
}
node = current.superview
hops += 1
}
return false
}
#if DEBUG
private func developerToolsShortcutProbeKind(event: NSEvent) -> String? {
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) {
return "toggle.configured"
}
if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) {
return "console.configured"
}
let chars = (event.charactersIgnoringModifiers ?? "").lowercased()
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags == [.command, .option] {
if chars == "i" || event.keyCode == 34 {
return "toggle.literal"
}
if chars == "c" || event.keyCode == 8 {
return "console.literal"
}
}
return nil
}
private func logDeveloperToolsShortcutSnapshot(
phase: String,
event: NSEvent? = nil,
didHandle: Bool? = nil
) {
let keyWindow = NSApp.keyWindow
let firstResponder = keyWindow?.firstResponder
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let eventDescription = event.map(NSWindow.keyDescription) ?? "none"
if let browser = tabManager?.focusedBrowserPanel {
var line =
"browser.devtools shortcut=\(phase) panel=\(browser.id.uuidString.prefix(5)) " +
"\(browser.debugDeveloperToolsStateSummary()) \(browser.debugDeveloperToolsGeometrySummary()) " +
"keyWin=\(keyWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
if let didHandle {
line += " handled=\(didHandle ? 1 : 0)"
}
dlog(line)
return
}
var line =
"browser.devtools shortcut=\(phase) panel=nil keyWin=\(keyWindow?.windowNumber ?? -1) " +
"fr=\(firstResponderType)@\(firstResponderPtr) event=\(eventDescription)"
if let didHandle {
line += " handled=\(didHandle ? 1 : 0)"
}
dlog(line)
}
#endif
private func prepareFocusedBrowserDevToolsForSplit(directionLabel: String) {
guard let browser = tabManager?.focusedBrowserPanel else { return }
guard browser.shouldPreserveWebViewAttachmentDuringTransientHide() else { return }
guard let keyWindow = NSApp.keyWindow else { return }
guard isLikelyWebInspectorResponder(keyWindow.firstResponder) else { return }
let beforeResponder = keyWindow.firstResponder
let movedToWebView = keyWindow.makeFirstResponder(browser.webView)
let movedToNil = movedToWebView ? false : keyWindow.makeFirstResponder(nil)
#if DEBUG
let beforeType = beforeResponder.map { String(describing: type(of: $0)) } ?? "nil"
let beforePtr = beforeResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let afterResponder = keyWindow.firstResponder
let afterType = afterResponder.map { String(describing: type(of: $0)) } ?? "nil"
let afterPtr = afterResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
dlog(
"split.shortcut inspector.preflight dir=\(directionLabel) panel=\(browser.id.uuidString.prefix(5)) " +
"before=\(beforeType)@\(beforePtr) after=\(afterType)@\(afterPtr) " +
"moveWeb=\(movedToWebView ? 1 : 0) moveNil=\(movedToNil ? 1 : 0) \(browser.debugDeveloperToolsStateSummary())"
)
#endif
}
@discardableResult
func performSplitShortcut(direction: SplitDirection) -> Bool {
let directionLabel: String
switch direction {
case .left: directionLabel = "left"
case .right: directionLabel = "right"
case .up: directionLabel = "up"
case .down: directionLabel = "down"
}
#if DEBUG
let keyWindow = NSApp.keyWindow
let firstResponder = keyWindow?.firstResponder
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let firstResponderWindow: Int = {
if let v = firstResponder as? NSView {
return v.window?.windowNumber ?? -1
}
if let w = firstResponder as? NSWindow {
return w.windowNumber
}
return -1
}()
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
if let browser = tabManager?.focusedBrowserPanel {
let webWindow = browser.webView.window?.windowNumber ?? -1
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
dlog("split.shortcut dir=\(directionLabel) pre panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
} else {
dlog("split.shortcut dir=\(directionLabel) pre panel=nil \(splitContext)")
}
#endif
prepareFocusedBrowserDevToolsForSplit(directionLabel: directionLabel)
tabManager?.createSplit(direction: direction)
#if DEBUG
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
let keyWindow = NSApp.keyWindow
let firstResponder = keyWindow?.firstResponder
let firstResponderType = firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
let firstResponderPtr = firstResponder.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
let firstResponderWindow: Int = {
if let v = firstResponder as? NSView {
return v.window?.windowNumber ?? -1
}
if let w = firstResponder as? NSWindow {
return w.windowNumber
}
return -1
}()
let splitContext = "keyWin=\(keyWindow?.windowNumber ?? -1) mainWin=\(NSApp.mainWindow?.windowNumber ?? -1) fr=\(firstResponderType)@\(firstResponderPtr) frWin=\(firstResponderWindow)"
if let browser = self?.tabManager?.focusedBrowserPanel {
let webWindow = browser.webView.window?.windowNumber ?? -1
let webSuperview = browser.webView.superview.map { String(describing: Unmanaged.passUnretained($0).toOpaque()) } ?? "nil"
dlog("split.shortcut dir=\(directionLabel) post panel=\(browser.id.uuidString.prefix(5)) \(browser.debugDeveloperToolsStateSummary()) webWin=\(webWindow) webSuper=\(webSuperview) \(splitContext)")
} else {
dlog("split.shortcut dir=\(directionLabel) post panel=nil \(splitContext)")
}
}
recordGotoSplitSplitIfNeeded(direction: direction)
#endif
return true
}
@discardableResult
func performBrowserSplitShortcut(direction: SplitDirection) -> Bool {
guard let panelId = tabManager?.createBrowserSplit(direction: direction) else { return false }
_ = focusBrowserAddressBar(panelId: panelId)
return true
}
/// Allow AppKit-backed browser surfaces (WKWebView) to route non-menu shortcuts
/// through the same app-level shortcut handler used by the local key monitor.
@discardableResult

View file

@ -0,0 +1,867 @@
import AppKit
import ObjectiveC
import WebKit
#if DEBUG
import Bonsplit
#endif
private var cmuxWindowBrowserPortalKey: UInt8 = 0
private var cmuxWindowBrowserPortalCloseObserverKey: UInt8 = 0
#if DEBUG
private func browserPortalDebugToken(_ view: NSView?) -> String {
guard let view else { return "nil" }
let ptr = Unmanaged.passUnretained(view).toOpaque()
return String(describing: ptr)
}
private func browserPortalDebugFrame(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
#endif
final class WindowBrowserHostView: NSView {
override var isOpaque: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
if shouldPassThroughToSplitDivider(at: point) {
return nil
}
let hitView = super.hitTest(point)
return hitView === self ? nil : hitView
}
private func shouldPassThroughToSplitDivider(at point: NSPoint) -> Bool {
guard let window else { return false }
let windowPoint = convert(point, to: nil)
guard let rootView = window.contentView else { return false }
return Self.containsSplitDivider(at: windowPoint, in: rootView)
}
private static func containsSplitDivider(at windowPoint: NSPoint, in view: NSView) -> Bool {
guard !view.isHidden else { return false }
if let splitView = view as? NSSplitView {
let pointInSplit = splitView.convert(windowPoint, from: nil)
if splitView.bounds.contains(pointInSplit) {
let expansion: CGFloat = 5
let dividerCount = max(0, splitView.arrangedSubviews.count - 1)
for dividerIndex in 0..<dividerCount {
let first = splitView.arrangedSubviews[dividerIndex].frame
let second = splitView.arrangedSubviews[dividerIndex + 1].frame
let thickness = splitView.dividerThickness
let dividerRect: NSRect
if splitView.isVertical {
guard first.width > 1, second.width > 1 else { continue }
let x = max(0, first.maxX)
dividerRect = NSRect(
x: x,
y: 0,
width: thickness,
height: splitView.bounds.height
)
} else {
guard first.height > 1, second.height > 1 else { continue }
let y = max(0, first.maxY)
dividerRect = NSRect(
x: 0,
y: y,
width: splitView.bounds.width,
height: thickness
)
}
let expanded = dividerRect.insetBy(dx: -expansion, dy: -expansion)
if expanded.contains(pointInSplit) {
return true
}
}
}
}
for subview in view.subviews.reversed() {
if containsSplitDivider(at: windowPoint, in: subview) {
return true
}
}
return false
}
}
final class WindowBrowserSlotView: NSView {
override var isOpaque: Bool { false }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.masksToBounds = true
translatesAutoresizingMaskIntoConstraints = true
autoresizingMask = []
}
@available(*, unavailable)
required init?(coder: NSCoder) {
nil
}
}
@MainActor
final class WindowBrowserPortal: NSObject {
private weak var window: NSWindow?
private let hostView = WindowBrowserHostView(frame: .zero)
private weak var installedContainerView: NSView?
private weak var installedReferenceView: NSView?
private var hasDeferredFullSyncScheduled = false
private struct Entry {
weak var webView: WKWebView?
weak var containerView: WindowBrowserSlotView?
weak var anchorView: NSView?
var visibleInUI: Bool
var zPriority: Int
}
private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:]
private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:]
init(window: NSWindow) {
self.window = window
super.init()
hostView.wantsLayer = true
hostView.layer?.masksToBounds = true
hostView.translatesAutoresizingMaskIntoConstraints = true
hostView.autoresizingMask = []
_ = ensureInstalled()
}
@discardableResult
private func ensureInstalled() -> Bool {
guard let window else { return false }
guard let (container, reference) = installationTarget(for: window) else { return false }
if hostView.superview !== container ||
installedContainerView !== container ||
installedReferenceView !== reference {
hostView.removeFromSuperview()
container.addSubview(hostView, positioned: .above, relativeTo: reference)
installedContainerView = container
installedReferenceView = reference
} else if !Self.isView(hostView, above: reference, in: container) {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
synchronizeHostFrameToReference()
return true
}
@discardableResult
private func synchronizeHostFrameToReference() -> Bool {
guard let container = installedContainerView,
let reference = installedReferenceView else {
return false
}
let frameInContainer = container.convert(reference.bounds, from: reference)
let hasFiniteFrame =
frameInContainer.origin.x.isFinite &&
frameInContainer.origin.y.isFinite &&
frameInContainer.size.width.isFinite &&
frameInContainer.size.height.isFinite
guard hasFiniteFrame else { return false }
if !Self.rectApproximatelyEqual(hostView.frame, frameInContainer) {
CATransaction.begin()
CATransaction.setDisableActions(true)
hostView.frame = frameInContainer
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.hostFrame.update host=\(browserPortalDebugToken(hostView)) " +
"frame=\(browserPortalDebugFrame(frameInContainer))"
)
#endif
}
return frameInContainer.width > 1 && frameInContainer.height > 1
}
private func installationTarget(for window: NSWindow) -> (container: NSView, reference: NSView)? {
guard let contentView = window.contentView else { return nil }
if contentView.className == "NSGlassEffectView",
let foreground = contentView.subviews.first(where: { $0 !== hostView }) {
return (contentView, foreground)
}
guard let themeFrame = contentView.superview else { return nil }
return (themeFrame, contentView)
}
private static func isHiddenOrAncestorHidden(_ view: NSView) -> Bool {
if view.isHidden { return true }
var current = view.superview
while let v = current {
if v.isHidden { return true }
current = v.superview
}
return false
}
private static func rectApproximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, epsilon: CGFloat = 0.5) -> Bool {
abs(lhs.origin.x - rhs.origin.x) <= epsilon &&
abs(lhs.origin.y - rhs.origin.y) <= epsilon &&
abs(lhs.size.width - rhs.size.width) <= epsilon &&
abs(lhs.size.height - rhs.size.height) <= epsilon
}
private static func frameExtendsOutsideBounds(_ frame: NSRect, bounds: NSRect, epsilon: CGFloat = 0.5) -> Bool {
frame.minX < bounds.minX - epsilon ||
frame.minY < bounds.minY - epsilon ||
frame.maxX > bounds.maxX + epsilon ||
frame.maxY > bounds.maxY + epsilon
}
#if DEBUG
private static func inspectorSubviewCount(in root: NSView) -> Int {
var stack: [NSView] = [root]
var count = 0
while let current = stack.popLast() {
for subview in current.subviews {
if String(describing: type(of: subview)).contains("WKInspector") {
count += 1
}
stack.append(subview)
}
}
return count
}
#endif
private static func isView(_ view: NSView, above reference: NSView, in container: NSView) -> Bool {
guard let viewIndex = container.subviews.firstIndex(of: view),
let referenceIndex = container.subviews.firstIndex(of: reference) else {
return false
}
return viewIndex > referenceIndex
}
private func ensureContainerView(for entry: Entry, webView: WKWebView) -> WindowBrowserSlotView {
if let existing = entry.containerView {
return existing
}
let created = WindowBrowserSlotView(frame: .zero)
#if DEBUG
dlog(
"browser.portal.container.create web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(created))"
)
#endif
return created
}
private func moveWebKitRelatedSubviewsIfNeeded(
from sourceSuperview: NSView,
to containerView: WindowBrowserSlotView,
primaryWebView: WKWebView,
reason: String
) {
guard sourceSuperview !== containerView else { return }
// When Web Inspector is docked, WebKit can inject companion WK* subviews
// next to the primary WKWebView. Move those with the web view so inspector
// UI state does not get orphaned in the old host during split churn.
let relatedSubviews = sourceSuperview.subviews.filter { view in
if view === primaryWebView { return true }
return String(describing: type(of: view)).contains("WK")
}
guard !relatedSubviews.isEmpty else { return }
#if DEBUG
dlog(
"browser.portal.reparent.batch reason=\(reason) source=\(browserPortalDebugToken(sourceSuperview)) " +
"container=\(browserPortalDebugToken(containerView)) count=\(relatedSubviews.count) " +
"sourceType=\(String(describing: type(of: sourceSuperview))) targetType=\(String(describing: type(of: containerView))) " +
"sourceFlipped=\(sourceSuperview.isFlipped ? 1 : 0) targetFlipped=\(containerView.isFlipped ? 1 : 0) " +
"sourceBounds=\(browserPortalDebugFrame(sourceSuperview.bounds)) targetBounds=\(browserPortalDebugFrame(containerView.bounds))"
)
#endif
for view in relatedSubviews {
let frameInWindow = sourceSuperview.convert(view.frame, to: nil)
let className = String(describing: type(of: view))
view.removeFromSuperview()
containerView.addSubview(view, positioned: .above, relativeTo: nil)
let convertedFrame = containerView.convert(frameInWindow, from: nil)
view.frame = convertedFrame
#if DEBUG
dlog(
"browser.portal.reparent.batch.item reason=\(reason) class=\(className) " +
"view=\(browserPortalDebugToken(view)) frameInWindow=\(browserPortalDebugFrame(frameInWindow)) " +
"converted=\(browserPortalDebugFrame(convertedFrame))"
)
#endif
}
}
func detachWebView(withId webViewId: ObjectIdentifier) {
guard let entry = entriesByWebViewId.removeValue(forKey: webViewId) else { return }
if let anchor = entry.anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
#if DEBUG
let hadContainerSuperview = (entry.containerView?.superview === hostView) ? 1 : 0
let hadWebSuperview = entry.webView?.superview == nil ? 0 : 1
dlog(
"browser.portal.detach web=\(browserPortalDebugToken(entry.webView)) " +
"container=\(browserPortalDebugToken(entry.containerView)) " +
"anchor=\(browserPortalDebugToken(entry.anchorView)) " +
"hadContainerSuperview=\(hadContainerSuperview) hadWebSuperview=\(hadWebSuperview)"
)
#endif
entry.webView?.removeFromSuperview()
entry.containerView?.removeFromSuperview()
}
func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard ensureInstalled() else { return }
let webViewId = ObjectIdentifier(webView)
let anchorId = ObjectIdentifier(anchorView)
let previousEntry = entriesByWebViewId[webViewId]
let containerView = ensureContainerView(
for: previousEntry ?? Entry(webView: nil, containerView: nil, anchorView: nil, visibleInUI: false, zPriority: 0),
webView: webView
)
if let previousWebViewId = webViewByAnchorId[anchorId], previousWebViewId != webViewId {
#if DEBUG
let previousToken = entriesByWebViewId[previousWebViewId]
.map { browserPortalDebugToken($0.webView) }
?? String(describing: previousWebViewId)
dlog(
"browser.portal.bind.replace anchor=\(browserPortalDebugToken(anchorView)) " +
"oldWeb=\(previousToken) newWeb=\(browserPortalDebugToken(webView))"
)
#endif
detachWebView(withId: previousWebViewId)
}
if let oldEntry = entriesByWebViewId[webViewId],
let oldAnchor = oldEntry.anchorView,
oldAnchor !== anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(oldAnchor))
}
webViewByAnchorId[anchorId] = webViewId
entriesByWebViewId[webViewId] = Entry(
webView: webView,
containerView: containerView,
anchorView: anchorView,
visibleInUI: visibleInUI,
zPriority: zPriority
)
let didChangeAnchor: Bool = {
guard let previousAnchor = previousEntry?.anchorView else { return true }
return previousAnchor !== anchorView
}()
let becameVisible = (previousEntry?.visibleInUI ?? false) == false && visibleInUI
let priorityIncreased = zPriority > (previousEntry?.zPriority ?? Int.min)
#if DEBUG
if previousEntry == nil ||
didChangeAnchor ||
becameVisible ||
priorityIncreased ||
webView.superview !== containerView ||
containerView.superview !== hostView {
dlog(
"browser.portal.bind web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) " +
"anchor=\(browserPortalDebugToken(anchorView)) prevAnchor=\(browserPortalDebugToken(previousEntry?.anchorView)) " +
"visible=\(visibleInUI ? 1 : 0) prevVisible=\((previousEntry?.visibleInUI ?? false) ? 1 : 0) " +
"z=\(zPriority) prevZ=\(previousEntry?.zPriority ?? Int.min)"
)
}
#endif
if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
"reason=attachContainer super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
if let sourceSuperview = webView.superview {
moveWebKitRelatedSubviewsIfNeeded(
from: sourceSuperview,
to: containerView,
primaryWebView: webView,
reason: "bind.attachContainer"
)
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
if containerView.superview !== hostView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
"reason=attach super=\(browserPortalDebugToken(containerView.superview))"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
} else if (becameVisible || priorityIncreased), hostView.subviews.last !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) reason=raise " +
"didChangeAnchor=\(didChangeAnchor ? 1 : 0) becameVisible=\(becameVisible ? 1 : 0) " +
"priorityIncreased=\(priorityIncreased ? 1 : 0)"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
}
synchronizeWebView(withId: webViewId, source: "bind")
pruneDeadEntries()
}
func synchronizeWebViewForAnchor(_ anchorView: NSView) {
pruneDeadEntries()
let anchorId = ObjectIdentifier(anchorView)
let primaryWebViewId = webViewByAnchorId[anchorId]
if let primaryWebViewId {
synchronizeWebView(withId: primaryWebViewId, source: "anchorPrimary")
}
synchronizeAllWebViews(excluding: primaryWebViewId, source: "anchorSecondary")
scheduleDeferredFullSynchronizeAll()
}
private func scheduleDeferredFullSynchronizeAll() {
guard !hasDeferredFullSyncScheduled else { return }
hasDeferredFullSyncScheduled = true
#if DEBUG
dlog("browser.portal.sync.defer.schedule entries=\(entriesByWebViewId.count)")
#endif
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.hasDeferredFullSyncScheduled = false
#if DEBUG
dlog("browser.portal.sync.defer.tick entries=\(self.entriesByWebViewId.count)")
#endif
self.synchronizeAllWebViews(excluding: nil, source: "deferredTick")
}
}
private func synchronizeAllWebViews(excluding webViewIdToSkip: ObjectIdentifier?, source: String) {
guard ensureInstalled() else { return }
pruneDeadEntries()
let webViewIds = Array(entriesByWebViewId.keys)
for webViewId in webViewIds {
if webViewId == webViewIdToSkip { continue }
synchronizeWebView(withId: webViewId, source: source)
}
}
private func synchronizeWebView(withId webViewId: ObjectIdentifier, source: String) {
guard ensureInstalled() else { return }
guard let entry = entriesByWebViewId[webViewId] else { return }
guard let webView = entry.webView else {
entriesByWebViewId.removeValue(forKey: webViewId)
return
}
guard let containerView = entry.containerView else {
entriesByWebViewId.removeValue(forKey: webViewId)
if let anchor = entry.anchorView {
webViewByAnchorId.removeValue(forKey: ObjectIdentifier(anchor))
}
return
}
guard let anchorView = entry.anchorView, let window else {
#if DEBUG
if !containerView.isHidden {
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=1 reason=missingAnchorOrWindow"
)
}
#endif
containerView.isHidden = true
return
}
guard anchorView.window === window else {
#if DEBUG
if !containerView.isHidden {
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=1 " +
"reason=anchorWindowMismatch anchorWindow=\(browserPortalDebugToken(anchorView.window?.contentView))"
)
}
#endif
containerView.isHidden = true
return
}
if containerView.superview !== hostView {
#if DEBUG
dlog(
"browser.portal.reparent container=\(browserPortalDebugToken(containerView)) " +
"reason=syncAttach super=\(browserPortalDebugToken(containerView.superview))"
)
#endif
hostView.addSubview(containerView, positioned: .above, relativeTo: nil)
}
if webView.superview !== containerView {
#if DEBUG
dlog(
"browser.portal.reparent web=\(browserPortalDebugToken(webView)) " +
"reason=syncAttachContainer super=\(browserPortalDebugToken(webView.superview)) " +
"container=\(browserPortalDebugToken(containerView))"
)
#endif
if let sourceSuperview = webView.superview {
moveWebKitRelatedSubviewsIfNeeded(
from: sourceSuperview,
to: containerView,
primaryWebView: webView,
reason: "sync.attachContainer"
)
} else {
containerView.addSubview(webView, positioned: .above, relativeTo: nil)
}
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = containerView.bounds
webView.needsLayout = true
webView.layoutSubtreeIfNeeded()
}
_ = synchronizeHostFrameToReference()
let frameInWindow = anchorView.convert(anchorView.bounds, to: nil)
let frameInHost = hostView.convert(frameInWindow, from: nil)
let hostBounds = hostView.bounds
let hasFiniteHostBounds =
hostBounds.origin.x.isFinite &&
hostBounds.origin.y.isFinite &&
hostBounds.size.width.isFinite &&
hostBounds.size.height.isFinite
let hostBoundsReady = hasFiniteHostBounds && hostBounds.width > 1 && hostBounds.height > 1
if !hostBoundsReady {
#if DEBUG
dlog(
"browser.portal.sync.defer container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) " +
"reason=hostBoundsNotReady host=\(browserPortalDebugFrame(hostBounds)) " +
"anchor=\(browserPortalDebugFrame(frameInHost)) visibleInUI=\(entry.visibleInUI ? 1 : 0)"
)
#endif
containerView.isHidden = true
scheduleDeferredFullSynchronizeAll()
return
}
let oldFrame = containerView.frame
let hasFiniteFrame =
frameInHost.origin.x.isFinite &&
frameInHost.origin.y.isFinite &&
frameInHost.size.width.isFinite &&
frameInHost.size.height.isFinite
let clampedFrame = frameInHost.intersection(hostBounds)
let hasVisibleIntersection =
!clampedFrame.isNull &&
clampedFrame.width > 1 &&
clampedFrame.height > 1
let targetFrame = hasVisibleIntersection ? clampedFrame : frameInHost
let anchorHidden = Self.isHiddenOrAncestorHidden(anchorView)
let tinyFrame = targetFrame.width <= 1 || targetFrame.height <= 1
let outsideHostBounds = !hasVisibleIntersection
let shouldHide =
!entry.visibleInUI ||
anchorHidden ||
tinyFrame ||
!hasFiniteFrame ||
outsideHostBounds
#if DEBUG
let frameWasClamped = hasFiniteFrame && !Self.rectApproximatelyEqual(frameInHost, targetFrame)
if frameWasClamped {
dlog(
"browser.portal.frame.clamp container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"raw=\(browserPortalDebugFrame(frameInHost)) clamped=\(browserPortalDebugFrame(targetFrame)) " +
"host=\(browserPortalDebugFrame(hostBounds))"
)
}
let collapsedToTiny = oldFrame.width > 1 && oldFrame.height > 1 && tinyFrame
let restoredFromTiny = (oldFrame.width <= 1 || oldFrame.height <= 1) && !tinyFrame
if collapsedToTiny {
dlog(
"browser.portal.frame.collapse container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
)
} else if restoredFromTiny {
dlog(
"browser.portal.frame.restore container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) anchor=\(browserPortalDebugToken(anchorView)) " +
"old=\(browserPortalDebugFrame(oldFrame)) new=\(browserPortalDebugFrame(targetFrame))"
)
}
#endif
if !Self.rectApproximatelyEqual(oldFrame, targetFrame) {
CATransaction.begin()
CATransaction.setDisableActions(true)
containerView.frame = targetFrame
CATransaction.commit()
}
let expectedContainerBounds = NSRect(origin: .zero, size: targetFrame.size)
if !Self.rectApproximatelyEqual(containerView.bounds, expectedContainerBounds) {
let oldContainerBounds = containerView.bounds
CATransaction.begin()
CATransaction.setDisableActions(true)
containerView.bounds = expectedContainerBounds
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.bounds.normalize container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) old=\(browserPortalDebugFrame(oldContainerBounds)) " +
"target=\(browserPortalDebugFrame(expectedContainerBounds))"
)
#endif
}
let containerBounds = containerView.bounds
let preNormalizeWebFrame = webView.frame
let inspectorHeightFromInsets = max(0, containerBounds.height - preNormalizeWebFrame.height)
let inspectorHeightFromOverflow = max(0, preNormalizeWebFrame.maxY - containerBounds.maxY)
let inspectorHeightApprox = max(inspectorHeightFromInsets, inspectorHeightFromOverflow)
#if DEBUG
let inspectorSubviews = Self.inspectorSubviewCount(in: containerView)
#endif
if Self.frameExtendsOutsideBounds(preNormalizeWebFrame, bounds: containerBounds) {
let oldWebFrame = preNormalizeWebFrame
CATransaction.begin()
CATransaction.setDisableActions(true)
webView.frame = containerBounds
CATransaction.commit()
#if DEBUG
dlog(
"browser.portal.webframe.normalize web=\(browserPortalDebugToken(webView)) " +
"container=\(browserPortalDebugToken(containerView)) old=\(browserPortalDebugFrame(oldWebFrame)) " +
"new=\(browserPortalDebugFrame(webView.frame)) bounds=\(browserPortalDebugFrame(containerBounds)) " +
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
"inspectorSubviews=\(inspectorSubviews) " +
"source=\(source)"
)
#endif
}
if containerView.isHidden != shouldHide {
#if DEBUG
dlog(
"browser.portal.hidden container=\(browserPortalDebugToken(containerView)) " +
"web=\(browserPortalDebugToken(webView)) value=\(shouldHide ? 1 : 0) " +
"visibleInUI=\(entry.visibleInUI ? 1 : 0) anchorHidden=\(anchorHidden ? 1 : 0) " +
"tiny=\(tinyFrame ? 1 : 0) finite=\(hasFiniteFrame ? 1 : 0) " +
"outside=\(outsideHostBounds ? 1 : 0) frame=\(browserPortalDebugFrame(targetFrame)) " +
"host=\(browserPortalDebugFrame(hostBounds))"
)
#endif
containerView.isHidden = shouldHide
}
#if DEBUG
dlog(
"browser.portal.sync.result web=\(browserPortalDebugToken(webView)) source=\(source) " +
"container=\(browserPortalDebugToken(containerView)) " +
"anchor=\(browserPortalDebugToken(anchorView)) host=\(browserPortalDebugToken(hostView)) " +
"hostWin=\(hostView.window?.windowNumber ?? -1) " +
"old=\(browserPortalDebugFrame(oldFrame)) raw=\(browserPortalDebugFrame(frameInHost)) " +
"target=\(browserPortalDebugFrame(targetFrame)) hide=\(shouldHide ? 1 : 0) " +
"entryVisible=\(entry.visibleInUI ? 1 : 0) " +
"containerHidden=\(containerView.isHidden ? 1 : 0) webHidden=\(webView.isHidden ? 1 : 0) " +
"containerBounds=\(browserPortalDebugFrame(containerView.bounds)) " +
"preWebFrame=\(browserPortalDebugFrame(preNormalizeWebFrame)) " +
"webFrame=\(browserPortalDebugFrame(webView.frame)) webBounds=\(browserPortalDebugFrame(webView.bounds)) " +
"inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) " +
"inspectorInsets=\(String(format: "%.1f", inspectorHeightFromInsets)) " +
"inspectorOverflow=\(String(format: "%.1f", inspectorHeightFromOverflow)) " +
"inspectorSubviews=\(inspectorSubviews)"
)
#endif
}
private func pruneDeadEntries() {
let currentWindow = window
let deadWebViewIds = entriesByWebViewId.compactMap { webViewId, entry -> ObjectIdentifier? in
guard entry.webView != nil else { return webViewId }
guard let container = entry.containerView else { return webViewId }
guard let anchor = entry.anchorView else { return webViewId }
if container.superview == nil || !container.isDescendant(of: hostView) {
return webViewId
}
if anchor.window !== currentWindow || anchor.superview == nil {
return webViewId
}
if let reference = installedReferenceView,
!anchor.isDescendant(of: reference) {
return webViewId
}
return nil
}
for webViewId in deadWebViewIds {
detachWebView(withId: webViewId)
}
let validAnchorIds = Set(entriesByWebViewId.compactMap { _, entry in
entry.anchorView.map { ObjectIdentifier($0) }
})
webViewByAnchorId = webViewByAnchorId.filter { validAnchorIds.contains($0.key) }
}
func webViewIds() -> Set<ObjectIdentifier> {
Set(entriesByWebViewId.keys)
}
func tearDown() {
for webViewId in Array(entriesByWebViewId.keys) {
detachWebView(withId: webViewId)
}
hostView.removeFromSuperview()
installedContainerView = nil
installedReferenceView = nil
}
#if DEBUG
func debugEntryCount() -> Int {
entriesByWebViewId.count
}
func debugHostedSubviewCount() -> Int {
hostView.subviews.count
}
#endif
func webViewAtWindowPoint(_ windowPoint: NSPoint) -> WKWebView? {
guard ensureInstalled() else { return nil }
let point = hostView.convert(windowPoint, from: nil)
for subview in hostView.subviews.reversed() {
guard let container = subview as? WindowBrowserSlotView else { continue }
guard !container.isHidden else { continue }
guard container.frame.contains(point) else { continue }
guard let webView = entriesByWebViewId
.first(where: { _, entry in entry.containerView === container })?
.value
.webView else { continue }
return webView
}
return nil
}
}
@MainActor
enum BrowserWindowPortalRegistry {
private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:]
private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:]
private static func installWindowCloseObserverIfNeeded(for window: NSWindow) {
guard objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) == nil else { return }
let windowId = ObjectIdentifier(window)
let observer = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification,
object: window,
queue: .main
) { [weak window] _ in
MainActor.assumeIsolated {
if let window {
removePortal(for: window)
} else {
removePortal(windowId: windowId, window: nil)
}
}
}
objc_setAssociatedObject(
window,
&cmuxWindowBrowserPortalCloseObserverKey,
observer,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
private static func removePortal(for window: NSWindow) {
removePortal(windowId: ObjectIdentifier(window), window: window)
}
private static func removePortal(windowId: ObjectIdentifier, window: NSWindow?) {
if let portal = portalsByWindowId.removeValue(forKey: windowId) {
portal.tearDown()
}
webViewToWindowId = webViewToWindowId.filter { $0.value != windowId }
guard let window else { return }
if let observer = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey) {
NotificationCenter.default.removeObserver(observer)
}
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalCloseObserverKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
private static func pruneWebViewMappings(for windowId: ObjectIdentifier, validWebViewIds: Set<ObjectIdentifier>) {
webViewToWindowId = webViewToWindowId.filter { webViewId, mappedWindowId in
mappedWindowId != windowId || validWebViewIds.contains(webViewId)
}
}
private static func portal(for window: NSWindow) -> WindowBrowserPortal {
if let existing = objc_getAssociatedObject(window, &cmuxWindowBrowserPortalKey) as? WindowBrowserPortal {
portalsByWindowId[ObjectIdentifier(window)] = existing
installWindowCloseObserverIfNeeded(for: window)
return existing
}
let portal = WindowBrowserPortal(window: window)
objc_setAssociatedObject(window, &cmuxWindowBrowserPortalKey, portal, .OBJC_ASSOCIATION_RETAIN)
portalsByWindowId[ObjectIdentifier(window)] = portal
installWindowCloseObserverIfNeeded(for: window)
return portal
}
static func bind(webView: WKWebView, to anchorView: NSView, visibleInUI: Bool, zPriority: Int = 0) {
guard let window = anchorView.window else { return }
let windowId = ObjectIdentifier(window)
let webViewId = ObjectIdentifier(webView)
let nextPortal = portal(for: window)
if let oldWindowId = webViewToWindowId[webViewId],
oldWindowId != windowId {
portalsByWindowId[oldWindowId]?.detachWebView(withId: webViewId)
}
nextPortal.bind(webView: webView, to: anchorView, visibleInUI: visibleInUI, zPriority: zPriority)
webViewToWindowId[webViewId] = windowId
pruneWebViewMappings(for: windowId, validWebViewIds: nextPortal.webViewIds())
}
static func synchronizeForAnchor(_ anchorView: NSView) {
guard let window = anchorView.window else { return }
let portal = portal(for: window)
portal.synchronizeWebViewForAnchor(anchorView)
}
static func detach(webView: WKWebView) {
let webViewId = ObjectIdentifier(webView)
guard let windowId = webViewToWindowId.removeValue(forKey: webViewId) else { return }
portalsByWindowId[windowId]?.detachWebView(withId: webViewId)
}
#if DEBUG
static func debugPortalCount() -> Int {
portalsByWindowId.count
}
#endif
}

View file

@ -3,6 +3,7 @@ import Bonsplit
import SwiftUI
import ObjectiveC
import UniformTypeIdentifiers
import WebKit
struct ShortcutHintPillBackground: View {
var emphasis: Double = 1.0
@ -224,6 +225,9 @@ final class FileDropOverlayView: NSView {
/// Fallback handler when no terminal is found under the drop point.
var onDrop: (([URL]) -> Bool)?
private var isForwardingMouseEvent = false
/// The WKWebView currently receiving forwarded drag events, so we can
/// synthesize draggingExited/draggingEntered as the cursor moves.
private weak var activeDragWebView: WKWebView?
override var acceptsFirstResponder: Bool { false }
@ -293,14 +297,26 @@ final class FileDropOverlayView: NSView {
override func otherMouseDragged(with event: NSEvent) { forwardEvent(event) }
override func scrollWheel(with event: NSEvent) { forwardEvent(event) }
// MARK: NSDraggingDestination only accept file drops over terminal views.
// MARK: NSDraggingDestination accept file drops over terminal and browser views.
//
// AppKit sends draggingEntered once when the drag enters this overlay, then
// draggingUpdated as the cursor moves within it. We track which WKWebView (if
// any) is under the cursor and synthesize enter/exit calls so the browser's
// HTML5 drag events (dragenter, dragleave, drop) fire correctly.
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
return dragOperationForSender(sender, phase: "entered")
return updateDragTarget(sender, phase: "entered")
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
return dragOperationForSender(sender, phase: "updated")
return updateDragTarget(sender, phase: "updated")
}
override func draggingExited(_ sender: (any NSDraggingInfo)?) {
if let prev = activeDragWebView {
prev.draggingExited(sender)
activeDragWebView = nil
}
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
@ -310,38 +326,58 @@ final class FileDropOverlayView: NSView {
pasteboardTypes: types,
hasLocalDraggingSource: hasLocalDraggingSource
)
let webView = activeDragWebView
activeDragWebView = nil
#if DEBUG
if shouldCapture || (types?.contains(.fileURL) ?? false) {
dlog(
"overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " +
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
"types=\(debugPasteboardTypes(types))"
)
}
dlog(
"overlay.fileDrop.perform capture=\(shouldCapture ? 1 : 0) " +
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
"hasWebView=\(webView != nil ? 1 : 0) " +
"types=\(debugPasteboardTypes(types))"
)
#endif
guard shouldCapture else { return false }
if let webView {
return webView.performDragOperation(sender)
}
guard let terminal = terminalUnderPoint(sender.draggingLocation) else { return false }
return terminal.performDragOperation(sender)
}
private func dragOperationForSender(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
private func updateDragTarget(_ sender: any NSDraggingInfo, phase: String) -> NSDragOperation {
let loc = sender.draggingLocation
let hasLocalDraggingSource = sender.draggingSource != nil
let types = sender.draggingPasteboard.types
let shouldCapture = DragOverlayRoutingPolicy.shouldCaptureFileDropDestination(
pasteboardTypes: types,
hasLocalDraggingSource: hasLocalDraggingSource
)
#if DEBUG
if shouldCapture || (types?.contains(.fileURL) ?? false) {
dlog(
"overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " +
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
"types=\(debugPasteboardTypes(types))"
)
let webView = shouldCapture ? webViewUnderPoint(loc) : nil
if let prev = activeDragWebView, prev !== webView {
prev.draggingExited(sender)
activeDragWebView = nil
}
if let webView {
if activeDragWebView !== webView {
activeDragWebView = webView
return webView.draggingEntered(sender)
}
return webView.draggingUpdated(sender)
}
let hasTerminalTarget = terminalUnderPoint(loc) != nil
#if DEBUG
dlog(
"overlay.fileDrop.\(phase) capture=\(shouldCapture ? 1 : 0) " +
"localSource=\(hasLocalDraggingSource ? 1 : 0) " +
"hasWebView=\(webView != nil ? 1 : 0) " +
"hasTerminal=\(hasTerminalTarget ? 1 : 0) " +
"types=\(debugPasteboardTypes(types))"
)
#endif
guard shouldCapture,
terminalUnderPoint(sender.draggingLocation) != nil else { return [] }
guard shouldCapture, hasTerminalTarget else { return [] }
return .copy
}
@ -350,6 +386,22 @@ final class FileDropOverlayView: NSView {
return types.map(\.rawValue).joined(separator: ",")
}
/// Hit-tests the window to find a WKWebView (browser panel) under the cursor.
private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? {
guard let window, let contentView = window.contentView else { return nil }
isHidden = true
defer { isHidden = false }
let point = contentView.convert(windowPoint, from: nil)
let hitView = contentView.hitTest(point)
var current: NSView? = hitView
while let view = current {
if let webView = view as? WKWebView { return webView }
current = view.superview
}
return nil
}
/// Hit-tests the window to find the GhosttyNSView under the cursor.
func terminalUnderPoint(_ windowPoint: NSPoint) -> GhosttyNSView? {
if let window,

View file

@ -679,7 +679,6 @@ class GhosttyApp {
let command = actionTitle.isEmpty ? tabTitle : actionTitle
let body = actionBody
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
tabManager.moveTabToTop(tabId)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,
@ -883,7 +882,6 @@ class GhosttyApp {
let tabTitle = AppDelegate.shared?.tabManager?.titleForTab(tabId) ?? "Terminal"
let command = actionTitle.isEmpty ? tabTitle : actionTitle
let body = actionBody
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
TerminalNotificationStore.shared.addNotification(
tabId: tabId,
surfaceId: surfaceId,

View file

@ -26,9 +26,13 @@ enum KeyboardShortcutSettings {
case focusDown
case splitRight
case splitDown
case splitBrowserRight
case splitBrowserDown
// Panels
case openBrowser
case toggleBrowserDeveloperTools
case showBrowserJavaScriptConsole
var id: String { rawValue }
@ -51,7 +55,11 @@ enum KeyboardShortcutSettings {
case .focusDown: return "Focus Pane Down"
case .splitRight: return "Split Right"
case .splitDown: return "Split Down"
case .splitBrowserRight: return "Split Browser Right"
case .splitBrowserDown: return "Split Browser Down"
case .openBrowser: return "Open Browser"
case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools"
case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console"
}
}
@ -71,10 +79,14 @@ enum KeyboardShortcutSettings {
case .focusDown: return "shortcut.focusDown"
case .splitRight: return "shortcut.splitRight"
case .splitDown: return "shortcut.splitDown"
case .splitBrowserRight: return "shortcut.splitBrowserRight"
case .splitBrowserDown: return "shortcut.splitBrowserDown"
case .nextSurface: return "shortcut.nextSurface"
case .prevSurface: return "shortcut.prevSurface"
case .newSurface: return "shortcut.newSurface"
case .openBrowser: return "shortcut.openBrowser"
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
}
}
@ -108,6 +120,10 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
case .splitDown:
return StoredShortcut(key: "d", command: true, shift: true, option: false, control: false)
case .splitBrowserRight:
return StoredShortcut(key: "d", command: true, shift: false, option: true, control: false)
case .splitBrowserDown:
return StoredShortcut(key: "d", command: true, shift: true, option: true, control: false)
case .nextSurface:
return StoredShortcut(key: "]", command: true, shift: true, option: false, control: false)
case .prevSurface:
@ -116,6 +132,12 @@ enum KeyboardShortcutSettings {
return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false)
case .openBrowser:
return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false)
case .toggleBrowserDeveloperTools:
// Safari default: Show Web Inspector.
return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false)
case .showBrowserJavaScriptConsole:
// Safari default: Show JavaScript Console.
return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false)
}
}
@ -176,12 +198,16 @@ enum KeyboardShortcutSettings {
static func splitRightShortcut() -> StoredShortcut { shortcut(for: .splitRight) }
static func splitDownShortcut() -> StoredShortcut { shortcut(for: .splitDown) }
static func splitBrowserRightShortcut() -> StoredShortcut { shortcut(for: .splitBrowserRight) }
static func splitBrowserDownShortcut() -> StoredShortcut { shortcut(for: .splitBrowserDown) }
static func nextSurfaceShortcut() -> StoredShortcut { shortcut(for: .nextSurface) }
static func prevSurfaceShortcut() -> StoredShortcut { shortcut(for: .prevSurface) }
static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) }
static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) }
static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) }
static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) }
}
/// A keyboard shortcut that can be stored in UserDefaults

View file

@ -2,6 +2,7 @@ import Foundation
import Combine
import WebKit
import AppKit
import Bonsplit
enum BrowserSearchEngine: String, CaseIterable, Identifiable {
case google
@ -75,6 +76,164 @@ enum BrowserLinkOpenSettings {
}
}
enum BrowserInsecureHTTPSettings {
static let allowlistKey = "browserInsecureHTTPAllowlist"
static let defaultAllowlistPatterns = [
"127.0.0.1",
"localhost",
"*.localtest.me",
]
static let defaultAllowlistText = defaultAllowlistPatterns.joined(separator: "\n")
static func normalizedAllowlistPatterns(defaults: UserDefaults = .standard) -> [String] {
normalizedAllowlistPatterns(rawValue: defaults.string(forKey: allowlistKey))
}
static func normalizedAllowlistPatterns(rawValue: String?) -> [String] {
let source: String
if let rawValue, !rawValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
source = rawValue
} else {
source = defaultAllowlistText
}
let parsed = parsePatterns(from: source)
return parsed.isEmpty ? defaultAllowlistPatterns : parsed
}
static func isHostAllowed(_ host: String, defaults: UserDefaults = .standard) -> Bool {
isHostAllowed(host, rawAllowlist: defaults.string(forKey: allowlistKey))
}
static func isHostAllowed(_ host: String, rawAllowlist: String?) -> Bool {
guard let normalizedHost = normalizeHost(host) else { return false }
return normalizedAllowlistPatterns(rawValue: rawAllowlist).contains { pattern in
hostMatchesPattern(normalizedHost, pattern: pattern)
}
}
static func addAllowedHost(_ host: String, defaults: UserDefaults = .standard) {
guard let normalizedHost = normalizeHost(host) else { return }
var patterns = normalizedAllowlistPatterns(defaults: defaults)
guard !patterns.contains(normalizedHost) else { return }
patterns.append(normalizedHost)
defaults.set(patterns.joined(separator: "\n"), forKey: allowlistKey)
}
static func normalizeHost(_ rawHost: String) -> String? {
var value = rawHost
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !value.isEmpty else { return nil }
if let parsed = URL(string: value)?.host {
return trimHost(parsed)
}
if let schemeRange = value.range(of: "://") {
value = String(value[schemeRange.upperBound...])
}
if let slash = value.firstIndex(where: { $0 == "/" || $0 == "?" || $0 == "#" }) {
value = String(value[..<slash])
}
if value.hasPrefix("[") {
if let closing = value.firstIndex(of: "]") {
value = String(value[value.index(after: value.startIndex)..<closing])
} else {
value.removeFirst()
}
} else if let colon = value.lastIndex(of: ":"),
value[value.index(after: colon)...].allSatisfy(\.isNumber),
value.filter({ $0 == ":" }).count == 1 {
value = String(value[..<colon])
}
return trimHost(value)
}
private static func parsePatterns(from rawValue: String) -> [String] {
let separators = CharacterSet(charactersIn: ",;\n\r\t")
var out: [String] = []
var seen = Set<String>()
for token in rawValue.components(separatedBy: separators) {
guard let normalized = normalizePattern(token) else { continue }
guard seen.insert(normalized).inserted else { continue }
out.append(normalized)
}
return out
}
private static func normalizePattern(_ rawPattern: String) -> String? {
let trimmed = rawPattern
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("*.") {
let suffixRaw = String(trimmed.dropFirst(2))
guard let suffix = normalizeHost(suffixRaw) else { return nil }
return "*.\(suffix)"
}
return normalizeHost(trimmed)
}
private static func hostMatchesPattern(_ host: String, pattern: String) -> Bool {
if pattern.hasPrefix("*.") {
let suffix = String(pattern.dropFirst(2))
return host == suffix || host.hasSuffix(".\(suffix)")
}
return host == pattern
}
private static func trimHost(_ raw: String) -> String? {
let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: "."))
return trimmed.isEmpty ? nil : trimmed
}
}
func browserShouldBlockInsecureHTTPURL(
_ url: URL,
defaults: UserDefaults = .standard
) -> Bool {
browserShouldBlockInsecureHTTPURL(
url,
rawAllowlist: defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
)
}
func browserShouldBlockInsecureHTTPURL(
_ url: URL,
rawAllowlist: String?
) -> Bool {
guard url.scheme?.lowercased() == "http" else { return false }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return true }
return !BrowserInsecureHTTPSettings.isHostAllowed(host, rawAllowlist: rawAllowlist)
}
func browserShouldConsumeOneTimeInsecureHTTPBypass(
_ url: URL,
bypassHostOnce: inout String?
) -> Bool {
guard let bypassHost = bypassHostOnce else { return false }
guard url.scheme?.lowercased() == "http",
let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
return false
}
guard host == bypassHost else { return false }
bypassHostOnce = nil
return true
}
func browserShouldPersistInsecureHTTPAllowlistSelection(
response: NSApplication.ModalResponse,
suppressionEnabled: Bool
) -> Bool {
guard suppressionEnabled else { return false }
return response == .alertFirstButtonReturn || response == .alertSecondButtonReturn
}
enum BrowserUserAgentSettings {
// Force a Safari UA. Some WebKit builds return a minimal UA without Version/Safari tokens,
// and some installs may have legacy Chrome UA overrides. Both can cause Google to serve
@ -769,6 +928,11 @@ actor BrowserSearchSuggestionService {
/// BrowserPanel provides a WKWebView-based browser panel.
/// All browser panels share a WKProcessPool for cookie sharing.
private enum BrowserInsecureHTTPNavigationIntent {
case currentTab
case newTab
}
@MainActor
final class BrowserPanel: Panel, ObservableObject {
/// Shared process pool for cookie sharing across all browser panels
@ -837,6 +1001,14 @@ final class BrowserPanel: Panel, ObservableObject {
private let minPageZoom: CGFloat = 0.25
private let maxPageZoom: CGFloat = 5.0
private let pageZoomStep: CGFloat = 0.1
private var insecureHTTPBypassHostOnce: String?
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
private var preferredDeveloperToolsVisible: Bool = false
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
private var developerToolsRestoreRetryAttempt: Int = 0
private let developerToolsRestoreRetryDelay: TimeInterval = 0.05
private let developerToolsRestoreRetryMaxAttempts: Int = 40
var displayTitle: String {
if !pageTitle.isEmpty {
@ -856,9 +1028,10 @@ final class BrowserPanel: Panel, ObservableObject {
false
}
init(workspaceId: UUID, initialURL: URL? = nil) {
init(workspaceId: UUID, initialURL: URL? = nil, bypassInsecureHTTPHostOnce: String? = nil) {
self.id = UUID()
self.workspaceId = workspaceId
self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "")
// Configure web view
let config = WKWebViewConfiguration()
@ -877,6 +1050,11 @@ final class BrowserPanel: Panel, ObservableObject {
let webView = CmuxWebView(frame: .zero, configuration: config)
webView.allowsBackForwardNavigationGestures = true
// Required for Web Inspector support on recent WebKit SDKs.
if #available(macOS 13.3, *) {
webView.isInspectable = true
}
// Match the empty-page background to the window so newly-created browsers
// don't flash white before content loads.
webView.underPageBackgroundColor = .windowBackgroundColor
@ -907,6 +1085,12 @@ final class BrowserPanel: Panel, ObservableObject {
navDelegate.openInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in
self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false
}
navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in
self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
}
webView.navigationDelegate = navDelegate
self.navigationDelegate = navDelegate
@ -916,6 +1100,9 @@ final class BrowserPanel: Panel, ObservableObject {
guard let self else { return }
self.openLinkInNewTab(url: url)
}
browserUIDelegate.requestNavigation = { [weak self] url, intent in
self?.requestNavigation(url, intent: intent)
}
webView.uiDelegate = browserUIDelegate
self.uiDelegate = browserUIDelegate
@ -1208,9 +1395,20 @@ final class BrowserPanel: Panel, ObservableObject {
// MARK: - Navigation
/// Navigate to a URL
func navigate(to url: URL) {
func navigate(to url: URL, recordTypedNavigation: Bool = false) {
if shouldBlockInsecureHTTPNavigation(to: url) {
presentInsecureHTTPAlert(for: url, intent: .currentTab, recordTypedNavigation: recordTypedNavigation)
return
}
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
}
private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) {
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
if recordTypedNavigation {
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
}
navigationDelegate?.lastAttemptedURL = url
var request = URLRequest(url: url)
// Behave like a normal browser (respect HTTP caching). Reload is handled separately.
@ -1226,8 +1424,7 @@ final class BrowserPanel: Panel, ObservableObject {
guard !trimmed.isEmpty else { return }
if let url = resolveNavigableURL(from: trimmed) {
BrowserHistoryStore.shared.recordTypedNavigation(url: url)
navigate(to: url)
navigate(to: url, recordTypedNavigation: true)
return
}
@ -1240,7 +1437,77 @@ final class BrowserPanel: Panel, ObservableObject {
resolveBrowserNavigableURL(input)
}
private func shouldBlockInsecureHTTPNavigation(to url: URL) -> Bool {
if browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce) {
return false
}
return browserShouldBlockInsecureHTTPURL(url)
}
private func requestNavigation(_ url: URL, intent: BrowserInsecureHTTPNavigationIntent) {
if shouldBlockInsecureHTTPNavigation(to: url) {
presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false)
return
}
switch intent {
case .currentTab:
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: false)
case .newTab:
openLinkInNewTab(url: url)
}
}
private func presentInsecureHTTPAlert(
for url: URL,
intent: BrowserInsecureHTTPNavigationIntent,
recordTypedNavigation: Bool
) {
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return }
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Connection isn't secure"
alert.informativeText = """
\(host) uses plain HTTP, so traffic can be read or modified on the network.
Open this URL in your default browser, or proceed in cmux.
"""
alert.addButton(withTitle: "Open in Default Browser")
alert.addButton(withTitle: "Proceed in cmux")
alert.addButton(withTitle: "Cancel")
alert.showsSuppressionButton = true
alert.suppressionButton?.title = "Always allow this host in cmux"
let response = alert.runModal()
if browserShouldPersistInsecureHTTPAllowlistSelection(
response: response,
suppressionEnabled: alert.suppressionButton?.state == .on
) {
BrowserInsecureHTTPSettings.addAllowedHost(host)
}
switch response {
case .alertFirstButtonReturn:
NSWorkspace.shared.open(url)
case .alertSecondButtonReturn:
switch intent {
case .currentTab:
insecureHTTPBypassHostOnce = host
navigateWithoutInsecureHTTPPrompt(to: url, recordTypedNavigation: recordTypedNavigation)
case .newTab:
openLinkInNewTab(url: url, bypassInsecureHTTPHostOnce: host)
}
default:
return
}
}
deinit {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
let webView = webView
Task { @MainActor in
BrowserWindowPortalRegistry.detach(webView: webView)
}
webViewObservers.removeAll()
}
}
@ -1290,11 +1557,16 @@ extension BrowserPanel {
}
/// Open a link in a new browser surface in the same pane
func openLinkInNewTab(url: URL) {
func openLinkInNewTab(url: URL, bypassInsecureHTTPHostOnce: String? = nil) {
guard let tabManager = AppDelegate.shared?.tabManager,
let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }),
let paneId = workspace.paneId(forPanelId: id) else { return }
workspace.newBrowserSurface(inPane: paneId, url: url, focus: true)
workspace.newBrowserSurface(
inPane: paneId,
url: url,
focus: true,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
)
}
/// Reload the current page
@ -1308,6 +1580,183 @@ extension BrowserPanel {
webView.stopLoading()
}
@discardableResult
func toggleDeveloperTools() -> Bool {
#if DEBUG
dlog(
"browser.devtools toggle.begin panel=\(id.uuidString.prefix(5)) " +
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
)
#endif
guard let inspector = webView.cmuxInspectorObject() else { return false }
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
let targetVisible = !visible
let selector = NSSelectorFromString(targetVisible ? "show" : "close")
guard inspector.responds(to: selector) else { return false }
inspector.cmuxCallVoid(selector: selector)
preferredDeveloperToolsVisible = targetVisible
if targetVisible {
developerToolsRestoreRetryAttempt = 0
} else {
cancelDeveloperToolsRestoreRetry()
forceDeveloperToolsRefreshOnNextAttach = false
}
#if DEBUG
dlog(
"browser.devtools toggle.end panel=\(id.uuidString.prefix(5)) targetVisible=\(targetVisible ? 1 : 0) " +
"\(debugDeveloperToolsStateSummary()) \(debugDeveloperToolsGeometrySummary())"
)
DispatchQueue.main.async { [weak self] in
guard let self else { return }
dlog(
"browser.devtools toggle.tick panel=\(self.id.uuidString.prefix(5)) " +
"\(self.debugDeveloperToolsStateSummary()) \(self.debugDeveloperToolsGeometrySummary())"
)
}
#endif
return true
}
@discardableResult
func showDeveloperTools() -> Bool {
guard let inspector = webView.cmuxInspectorObject() else { return false }
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if !visible {
let showSelector = NSSelectorFromString("show")
guard inspector.responds(to: showSelector) else { return false }
inspector.cmuxCallVoid(selector: showSelector)
}
preferredDeveloperToolsVisible = true
if (inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false) {
cancelDeveloperToolsRestoreRetry()
} else {
scheduleDeveloperToolsRestoreRetry()
}
return true
}
@discardableResult
func showDeveloperToolsConsole() -> Bool {
guard showDeveloperTools() else { return false }
guard let inspector = webView.cmuxInspectorObject() else { return true }
// WebKit private inspector API differs by OS; try known console selectors.
let consoleSelectors = [
"showConsole",
"showConsoleTab",
"showConsoleView",
]
for raw in consoleSelectors {
let selector = NSSelectorFromString(raw)
if inspector.responds(to: selector) {
inspector.cmuxCallVoid(selector: selector)
break
}
}
return true
}
/// Called before WKWebView detaches so manual inspector closes are respected.
func syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: Bool = false) {
guard let inspector = webView.cmuxInspectorObject() else { return }
guard let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) else { return }
if visible {
preferredDeveloperToolsVisible = true
cancelDeveloperToolsRestoreRetry()
return
}
if preserveVisibleIntent && preferredDeveloperToolsVisible {
return
}
preferredDeveloperToolsVisible = false
cancelDeveloperToolsRestoreRetry()
}
/// Called after WKWebView reattaches to keep inspector stable across split/layout churn.
func restoreDeveloperToolsAfterAttachIfNeeded() {
guard preferredDeveloperToolsVisible else {
cancelDeveloperToolsRestoreRetry()
forceDeveloperToolsRefreshOnNextAttach = false
return
}
guard let inspector = webView.cmuxInspectorObject() else {
scheduleDeveloperToolsRestoreRetry()
return
}
let shouldForceRefresh = forceDeveloperToolsRefreshOnNextAttach
forceDeveloperToolsRefreshOnNextAttach = false
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visible {
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.consumeVisible panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
}
#endif
cancelDeveloperToolsRestoreRetry()
return
}
let selector = NSSelectorFromString("show")
guard inspector.responds(to: selector) else {
cancelDeveloperToolsRestoreRetry()
return
}
#if DEBUG
if shouldForceRefresh {
dlog("browser.devtools refresh.forceShowWhenHidden panel=\(id.uuidString.prefix(5)) \(debugDeveloperToolsStateSummary())")
}
#endif
inspector.cmuxCallVoid(selector: selector)
preferredDeveloperToolsVisible = true
let visibleAfterShow = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visibleAfterShow {
cancelDeveloperToolsRestoreRetry()
} else {
scheduleDeveloperToolsRestoreRetry()
}
}
@discardableResult
func isDeveloperToolsVisible() -> Bool {
guard let inspector = webView.cmuxInspectorObject() else { return false }
return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
}
@discardableResult
func hideDeveloperTools() -> Bool {
guard let inspector = webView.cmuxInspectorObject() else { return false }
let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false
if visible {
let selector = NSSelectorFromString("close")
guard inspector.responds(to: selector) else { return false }
inspector.cmuxCallVoid(selector: selector)
}
preferredDeveloperToolsVisible = false
forceDeveloperToolsRefreshOnNextAttach = false
cancelDeveloperToolsRestoreRetry()
return true
}
/// During split/layout transitions SwiftUI can briefly mark the browser surface hidden
/// while its container is off-window. Avoid detaching in that transient phase if
/// DevTools is intended to remain open, because detach/reattach can blank inspector content.
func shouldPreserveWebViewAttachmentDuringTransientHide() -> Bool {
preferredDeveloperToolsVisible
}
func requestDeveloperToolsRefreshAfterNextAttach(reason: String) {
guard preferredDeveloperToolsVisible else { return }
forceDeveloperToolsRefreshOnNextAttach = true
#if DEBUG
dlog("browser.devtools refresh.request panel=\(id.uuidString.prefix(5)) reason=\(reason) \(debugDeveloperToolsStateSummary())")
#endif
}
func hasPendingDeveloperToolsRefreshAfterAttach() -> Bool {
forceDeveloperToolsRefreshOnNextAttach
}
@discardableResult
func zoomIn() -> Bool {
applyPageZoom(webView.pageZoom + pageZoomStep)
@ -1416,6 +1865,84 @@ extension BrowserPanel {
}
private extension BrowserPanel {
func scheduleDeveloperToolsRestoreRetry() {
guard preferredDeveloperToolsVisible else { return }
guard developerToolsRestoreRetryWorkItem == nil else { return }
guard developerToolsRestoreRetryAttempt < developerToolsRestoreRetryMaxAttempts else { return }
developerToolsRestoreRetryAttempt += 1
let work = DispatchWorkItem { [weak self] in
guard let self else { return }
self.developerToolsRestoreRetryWorkItem = nil
self.restoreDeveloperToolsAfterAttachIfNeeded()
}
developerToolsRestoreRetryWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + developerToolsRestoreRetryDelay, execute: work)
}
func cancelDeveloperToolsRestoreRetry() {
developerToolsRestoreRetryWorkItem?.cancel()
developerToolsRestoreRetryWorkItem = nil
developerToolsRestoreRetryAttempt = 0
}
}
#if DEBUG
extension BrowserPanel {
private static func debugRectDescription(_ rect: NSRect) -> String {
String(
format: "%.1f,%.1f %.1fx%.1f",
rect.origin.x,
rect.origin.y,
rect.size.width,
rect.size.height
)
}
private static func debugObjectToken(_ object: AnyObject?) -> String {
guard let object else { return "nil" }
return String(describing: Unmanaged.passUnretained(object).toOpaque())
}
private static func debugInspectorSubviewCount(in root: NSView) -> Int {
var stack: [NSView] = [root]
var count = 0
while let current = stack.popLast() {
for subview in current.subviews {
if String(describing: type(of: subview)).contains("WKInspector") {
count += 1
}
stack.append(subview)
}
}
return count
}
func debugDeveloperToolsStateSummary() -> String {
let preferred = preferredDeveloperToolsVisible ? 1 : 0
let visible = isDeveloperToolsVisible() ? 1 : 0
let inspector = webView.cmuxInspectorObject() == nil ? 0 : 1
let attached = webView.superview == nil ? 0 : 1
let inWindow = webView.window == nil ? 0 : 1
let forceRefresh = forceDeveloperToolsRefreshOnNextAttach ? 1 : 0
return "pref=\(preferred) vis=\(visible) inspector=\(inspector) attached=\(attached) inWindow=\(inWindow) restoreRetry=\(developerToolsRestoreRetryAttempt) forceRefresh=\(forceRefresh)"
}
func debugDeveloperToolsGeometrySummary() -> String {
let container = webView.superview
let containerBounds = container?.bounds ?? .zero
let webFrame = webView.frame
let inspectorInsets = max(0, containerBounds.height - webFrame.height)
let inspectorOverflow = max(0, webFrame.maxY - containerBounds.maxY)
let inspectorHeightApprox = max(inspectorInsets, inspectorOverflow)
let inspectorSubviews = container.map { Self.debugInspectorSubviewCount(in: $0) } ?? 0
let containerType = container.map { String(describing: type(of: $0)) } ?? "nil"
return "webFrame=\(Self.debugRectDescription(webFrame)) webBounds=\(Self.debugRectDescription(webView.bounds)) webWin=\(webView.window?.windowNumber ?? -1) super=\(Self.debugObjectToken(container)) superType=\(containerType) superBounds=\(Self.debugRectDescription(containerBounds)) inspectorHApprox=\(String(format: "%.1f", inspectorHeightApprox)) inspectorInsets=\(String(format: "%.1f", inspectorInsets)) inspectorOverflow=\(String(format: "%.1f", inspectorOverflow)) inspectorSubviews=\(inspectorSubviews)"
}
}
#endif
private extension BrowserPanel {
@discardableResult
func applyPageZoom(_ candidate: CGFloat) -> Bool {
@ -1439,12 +1966,41 @@ private extension BrowserPanel {
}
}
private extension WKWebView {
func cmuxInspectorObject() -> NSObject? {
let selector = NSSelectorFromString("_inspector")
guard responds(to: selector),
let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else {
return nil
}
return inspector
}
}
private extension NSObject {
func cmuxCallBool(selector: Selector) -> Bool? {
guard responds(to: selector) else { return nil }
typealias Fn = @convention(c) (AnyObject, Selector) -> Bool
let fn = unsafeBitCast(method(for: selector), to: Fn.self)
return fn(self, selector)
}
func cmuxCallVoid(selector: Selector) {
guard responds(to: selector) else { return }
typealias Fn = @convention(c) (AnyObject, Selector) -> Void
let fn = unsafeBitCast(method(for: selector), to: Fn.self)
fn(self, selector)
}
}
// MARK: - Navigation Delegate
private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
var didFinish: ((WKWebView) -> Void)?
var didFailNavigation: ((WKWebView, String) -> Void)?
var openInNewTab: ((URL) -> Void)?
var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)?
var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)?
/// The URL of the last navigation that was attempted. Used to preserve the omnibar URL
/// when a provisional navigation fails (e.g. connection refused on localhost:3000).
var lastAttemptedURL: URL?
@ -1564,6 +2120,21 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
if let url = navigationAction.request.url,
navigationAction.targetFrame?.isMainFrame != false,
shouldBlockInsecureHTTPNavigation?(url) == true {
let intent: BrowserInsecureHTTPNavigationIntent
if navigationAction.navigationType == .linkActivated,
navigationAction.modifierFlags.contains(.command) {
intent = .newTab
} else {
intent = .currentTab
}
handleBlockedInsecureHTTPNavigation?(url, intent)
decisionHandler(.cancel)
return
}
// target=_blank or window.open() navigate in the current webview
if navigationAction.targetFrame == nil,
let url = navigationAction.request.url {
@ -1589,6 +2160,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
private class BrowserUIDelegate: NSObject, WKUIDelegate {
var openInNewTab: ((URL) -> Void)?
var requestNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)?
/// Returning nil tells WebKit not to open a new window.
/// Cmd+click opens in a new tab; regular target=_blank navigates in-place.
@ -1599,7 +2171,11 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
if navigationAction.modifierFlags.contains(.command) {
if let requestNavigation {
let intent: BrowserInsecureHTTPNavigationIntent =
navigationAction.modifierFlags.contains(.command) ? .newTab : .currentTab
requestNavigation(url, intent)
} else if navigationAction.modifierFlags.contains(.command) {
openInNewTab?(url)
} else {
webView.load(URLRequest(url: url))
@ -1607,4 +2183,20 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate {
}
return nil
}
/// Handle <input type="file"> elements by presenting the native file picker.
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)
}
}
}

View file

@ -3,6 +3,113 @@ import SwiftUI
import WebKit
import AppKit
enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable {
case wrenchAndScrewdriver = "wrench.and.screwdriver"
case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill"
case curlyBracesSquare = "curlybraces.square"
case curlyBraces = "curlybraces"
case terminalFill = "terminal.fill"
case terminal = "terminal"
case hammer = "hammer"
case hammerCircle = "hammer.circle"
case ladybug = "ladybug"
case ladybugFill = "ladybug.fill"
case scope = "scope"
case codeChevrons = "chevron.left.slash.chevron.right"
case gearshape = "gearshape"
case gearshapeFill = "gearshape.fill"
case globe = "globe"
case globeAmericas = "globe.americas.fill"
var id: String { rawValue }
var title: String {
switch self {
case .wrenchAndScrewdriver: return "Wrench + Screwdriver"
case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)"
case .curlyBracesSquare: return "Curly Braces"
case .curlyBraces: return "Curly Braces (Plain)"
case .terminalFill: return "Terminal (Fill)"
case .terminal: return "Terminal"
case .hammer: return "Hammer"
case .hammerCircle: return "Hammer Circle"
case .ladybug: return "Bug"
case .ladybugFill: return "Bug (Fill)"
case .scope: return "Scope"
case .codeChevrons: return "Code Chevrons"
case .gearshape: return "Gear"
case .gearshapeFill: return "Gear (Fill)"
case .globe: return "Globe"
case .globeAmericas: return "Globe Americas (Fill)"
}
}
}
enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable {
case bonsplitInactive
case bonsplitActive
case accent
case tertiary
var id: String { rawValue }
var title: String {
switch self {
case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)"
case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)"
case .accent: return "Accent"
case .tertiary: return "Tertiary"
}
}
var color: Color {
switch self {
case .bonsplitInactive:
// Matches Bonsplit tab icon tint for inactive tabs.
return Color(nsColor: .secondaryLabelColor)
case .bonsplitActive:
// Matches Bonsplit tab icon tint for active tabs.
return Color(nsColor: .labelColor)
case .accent:
return .accentColor
case .tertiary:
return Color(nsColor: .tertiaryLabelColor)
}
}
}
enum BrowserDevToolsButtonDebugSettings {
static let iconNameKey = "browserDevToolsIconName"
static let iconColorKey = "browserDevToolsIconColor"
static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver
static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive
static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption {
guard let raw = defaults.string(forKey: iconNameKey),
let option = BrowserDevToolsIconOption(rawValue: raw) else {
return defaultIcon
}
return option
}
static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption {
guard let raw = defaults.string(forKey: iconColorKey),
let option = BrowserDevToolsIconColorOption(rawValue: raw) else {
return defaultColor
}
return option
}
static func copyPayload(defaults: UserDefaults = .standard) -> String {
let icon = iconOption(defaults: defaults)
let color = colorOption(defaults: defaults)
return """
browserDevToolsIconName=\(icon.rawValue)
browserDevToolsIconColor=\(color.rawValue)
"""
}
}
struct OmnibarInlineCompletion: Equatable {
let typedText: String
let displayText: String
@ -20,11 +127,14 @@ struct BrowserPanelView: View {
@ObservedObject var panel: BrowserPanel
let isFocused: Bool
let isVisibleInUI: Bool
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@State private var omnibarState = OmnibarState()
@State private var addressBarFocused: Bool = false
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
@State private var suggestionTask: Task<Void, Never>?
@State private var isLoadingRemoteSuggestions: Bool = false
@State private var latestRemoteSuggestionQuery: String = ""
@ -38,6 +148,8 @@ struct BrowserPanelView: View {
@State private var omnibarPillFrame: CGRect = .zero
@State private var lastHandledAddressBarFocusRequestId: UUID?
private let omnibarPillCornerRadius: CGFloat = 12
private let addressBarButtonSize: CGFloat = 22
private let devToolsButtonIconSize: CGFloat = 11
private var searchEngine: BrowserSearchEngine {
BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine
@ -63,6 +175,14 @@ struct BrowserPanelView: View {
return searchSuggestionsEnabled
}
private var devToolsIconOption: BrowserDevToolsIconOption {
BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
}
private var devToolsColorOption: BrowserDevToolsIconColorOption {
BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
}
var body: some View {
VStack(spacing: 0) {
addressBar
@ -210,6 +330,8 @@ struct BrowserPanelView: View {
omnibarField
.accessibilityIdentifier("BrowserOmnibarPill")
.accessibilityLabel("Browser omnibar")
developerToolsButton
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
@ -219,8 +341,6 @@ struct BrowserPanelView: View {
}
private var addressBarButtonBar: some View {
let navButtonSize: CGFloat = 22
return HStack(spacing: 0) {
Button(action: {
#if DEBUG
@ -230,10 +350,10 @@ struct BrowserPanelView: View {
}) {
Image(systemName: "chevron.left")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.disabled(!panel.canGoBack)
.opacity(panel.canGoBack ? 1.0 : 0.4)
.help("Go Back")
@ -246,10 +366,10 @@ struct BrowserPanelView: View {
}) {
Image(systemName: "chevron.right")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.disabled(!panel.canGoForward)
.opacity(panel.canGoForward ? 1.0 : 0.4)
.help("Go Forward")
@ -269,14 +389,29 @@ struct BrowserPanelView: View {
}) {
Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise")
.font(.system(size: 12, weight: .medium))
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: navButtonSize, height: navButtonSize, alignment: .center)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.help(panel.isLoading ? "Stop" : "Reload")
}
}
private var developerToolsButton: some View {
Button(action: {
openDevTools()
}) {
Image(systemName: devToolsIconOption.rawValue)
.font(.system(size: devToolsButtonIconSize, weight: .medium))
.foregroundStyle(devToolsColorOption.color)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
}
.buttonStyle(.plain)
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
.help("Toggle Developer Tools")
.accessibilityIdentifier("BrowserToggleDevToolsButton")
}
private var omnibarField: some View {
let showSecureBadge = panel.currentURL?.scheme == "https"
@ -370,7 +505,8 @@ struct BrowserPanelView: View {
panel: panel,
shouldAttachWebView: isVisibleInUI,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused
isPanelFocused: isFocused,
portalZPriority: portalPriority
)
// Keep the representable identity stable across bonsplit structural updates.
// This reduces WKWebView reparenting churn (and the associated WebKit crashes).
@ -384,12 +520,6 @@ struct BrowserPanelView: View {
}
})
.zIndex(0)
.contextMenu {
Button("Open Developer Tools") {
openDevTools()
}
.keyboardShortcut("i", modifiers: [.command, .option])
}
}
private func triggerFocusFlashAnimation() {
@ -453,10 +583,11 @@ struct BrowserPanelView: View {
}
private func openDevTools() {
// WKWebView with developerExtrasEnabled allows right-click > Inspect Element
// We can also trigger via JavaScript
Task {
try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')")
#if DEBUG
dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))")
#endif
if !panel.toggleDeveloperTools() {
NSSound.beep()
}
}
@ -1071,7 +1202,19 @@ func buildOmnibarSuggestions(
)
order += 1
if let existing = bestByCompletion[key] {
if ranked.score > existing.score {
let shouldReplaceExisting: Bool = {
// For identical completions, keep "go to URL" over "switch to tab" so
// pressing Enter performs navigation unless the user explicitly picks a tab row.
switch (existing.suggestion.kind, ranked.suggestion.kind) {
case (.navigate, .switchToTab):
return false
case (.switchToTab, .navigate):
return true
default:
return ranked.score > existing.score
}
}()
if shouldReplaceExisting {
bestByCompletion[key] = ranked
}
} else {
@ -1970,6 +2113,8 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
parent.onMoveSelection(-1)
return true
case #selector(NSResponder.insertNewline(_:)):
let currentFlags = NSApp.currentEvent?.modifierFlags ?? []
guard browserOmnibarShouldSubmitOnReturn(flags: currentFlags) else { return false }
parent.onSubmit()
return true
case #selector(NSResponder.cancelOperation(_:)):
@ -2080,6 +2225,7 @@ private struct OmnibarTextFieldRepresentable: NSViewRepresentable {
switch keyCode {
case 36, 76: // Return / keypad Enter
guard browserOmnibarShouldSubmitOnReturn(flags: event.modifierFlags) else { return false }
parent.onSubmit()
return true
case 53: // Escape
@ -2431,15 +2577,88 @@ struct WebViewRepresentable: NSViewRepresentable {
let shouldAttachWebView: Bool
let shouldFocusWebView: Bool
let isPanelFocused: Bool
let portalZPriority: Int
final class Coordinator {
weak var panel: BrowserPanel?
weak var webView: WKWebView?
var constraints: [NSLayoutConstraint] = []
var attachRetryWorkItem: DispatchWorkItem?
var attachRetryCount: Int = 0
var attachGeneration: Int = 0
var usesWindowPortal: Bool = false
var desiredPortalVisibleInUI: Bool = true
var desiredPortalZPriority: Int = 0
var lastPortalHostId: ObjectIdentifier?
}
private final class HostContainerView: NSView {
var onDidMoveToWindow: (() -> Void)?
var onGeometryChanged: (() -> Void)?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
onDidMoveToWindow?()
onGeometryChanged?()
}
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
onGeometryChanged?()
}
override func layout() {
super.layout()
onGeometryChanged?()
}
override func setFrameOrigin(_ newOrigin: NSPoint) {
super.setFrameOrigin(newOrigin)
onGeometryChanged?()
}
override func setFrameSize(_ newSize: NSSize) {
super.setFrameSize(newSize)
onGeometryChanged?()
}
}
#if DEBUG
private static func logDevToolsState(
_ panel: BrowserPanel,
event: String,
generation: Int,
retryCount: Int,
details: String? = nil
) {
var line = "browser.devtools event=\(event) panel=\(panel.id.uuidString.prefix(5)) generation=\(generation) retry=\(retryCount) \(panel.debugDeveloperToolsStateSummary())"
if let details, !details.isEmpty {
line += " \(details)"
}
dlog(line)
}
private static func objectID(_ object: AnyObject?) -> String {
guard let object else { return "nil" }
return String(describing: Unmanaged.passUnretained(object).toOpaque())
}
private static func responderDescription(_ responder: NSResponder?) -> String {
guard let responder else { return "nil" }
return "\(type(of: responder))@\(objectID(responder))"
}
private static func rectDescription(_ rect: NSRect) -> String {
String(format: "%.1f,%.1f %.1fx%.1f", rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
}
private static func attachContext(webView: WKWebView, host: NSView) -> String {
let hostWindow = host.window?.windowNumber ?? -1
let webWindow = webView.window?.windowNumber ?? -1
let firstResponder = (webView.window ?? host.window)?.firstResponder
return "host=\(objectID(host)) hostWin=\(hostWindow) hostInWin=\(host.window == nil ? 0 : 1) hostFrame=\(rectDescription(host.frame)) hostBounds=\(rectDescription(host.bounds)) oldSuper=\(objectID(webView.superview)) webWin=\(webWindow) webInWin=\(webView.window == nil ? 0 : 1) webFrame=\(rectDescription(webView.frame)) webHidden=\(webView.isHidden ? 1 : 0) fr=\(responderDescription(firstResponder))"
}
#endif
private static func responderChainContains(_ start: NSResponder?, target: NSResponder) -> Bool {
var r = start
var hops = 0
@ -2451,22 +2670,141 @@ struct WebViewRepresentable: NSViewRepresentable {
return false
}
private static func isLikelyInspectorResponder(_ responder: NSResponder?) -> Bool {
guard let responder else { return false }
let responderType = String(describing: type(of: responder))
if responderType.contains("WKInspector") {
return true
}
guard let view = responder as? NSView else { return false }
var node: NSView? = view
var hops = 0
while let current = node, hops < 64 {
if String(describing: type(of: current)).contains("WKInspector") {
return true
}
node = current.superview
hops += 1
}
return false
}
private static func firstResponderResignState(
_ responder: NSResponder?,
webView: WKWebView
) -> (needsResign: Bool, flags: String) {
let inWebViewChain = responderChainContains(responder, target: webView)
let inspectorResponder = isLikelyInspectorResponder(responder)
let needsResign = inWebViewChain || inspectorResponder
return (
needsResign: needsResign,
flags: "frInWebChain=\(inWebViewChain ? 1 : 0) frIsInspector=\(inspectorResponder ? 1 : 0)"
)
}
func makeCoordinator() -> Coordinator {
Coordinator()
let coordinator = Coordinator()
coordinator.panel = panel
return coordinator
}
func makeNSView(context: Context) -> NSView {
let container = NSView()
let container = HostContainerView()
container.wantsLayer = true
return container
}
private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) {
private static func clearPortalCallbacks(for host: NSView) {
guard let host = host as? HostContainerView else { return }
host.onDidMoveToWindow = nil
host.onGeometryChanged = nil
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
guard let host = nsView as? HostContainerView else { return }
let coordinator = context.coordinator
let previousVisible = coordinator.desiredPortalVisibleInUI
let previousZPriority = coordinator.desiredPortalZPriority
coordinator.desiredPortalVisibleInUI = shouldAttachWebView
coordinator.desiredPortalZPriority = portalZPriority
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
guard let host, let webView, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard host.window != nil else { return }
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = ObjectIdentifier(host)
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
guard coordinator.attachGeneration == generation else { return }
guard coordinator.lastPortalHostId == ObjectIdentifier(host) else { return }
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
}
if !shouldAttachWebView {
// In portal mode we no longer detach/re-attach to preserve DevTools state.
// Sync the inspector preference directly so manual closes are respected.
panel.syncDeveloperToolsPreferenceFromInspector()
}
if host.window != nil {
let hostId = ObjectIdentifier(host)
let shouldBindNow =
coordinator.lastPortalHostId != hostId ||
webView.superview == nil ||
previousVisible != shouldAttachWebView ||
previousZPriority != portalZPriority
if shouldBindNow {
BrowserWindowPortalRegistry.bind(
webView: webView,
to: host,
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
}
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
Self.logDevToolsState(
panel,
event: "portal.update",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: host)
)
#endif
}
private static func attachWebView(_ webView: WKWebView, to host: NSView) {
// WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder
// while being detached/reparented during bonsplit/SwiftUI structural updates.
if let window = webView.window,
responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
if let window = webView.window {
let state = firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
window.makeFirstResponder(nil)
}
}
// The target host can already be in-window while the source host is tearing down.
// Re-check against the target window too (it can differ during split churn).
if let window = host.window {
let state = firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
window.makeFirstResponder(nil)
}
}
// Detach from any previous host (bonsplit/SwiftUI may rearrange views).
@ -2474,15 +2812,11 @@ struct WebViewRepresentable: NSViewRepresentable {
host.subviews.forEach { $0.removeFromSuperview() }
host.addSubview(webView)
webView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.deactivate(coordinator.constraints)
coordinator.constraints = [
webView.leadingAnchor.constraint(equalTo: host.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: host.trailingAnchor),
webView.topAnchor.constraint(equalTo: host.topAnchor),
webView.bottomAnchor.constraint(equalTo: host.bottomAnchor),
]
NSLayoutConstraint.activate(coordinator.constraints)
// Work around WebKit bug 272474 where Inspect Element can render blank/flicker
// when WKWebView is edge-pinned using Auto Layout constraints.
webView.translatesAutoresizingMaskIntoConstraints = true
webView.autoresizingMask = [.width, .height]
webView.frame = host.bounds
// Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out.
webView.needsLayout = true
@ -2491,7 +2825,13 @@ struct WebViewRepresentable: NSViewRepresentable {
webView.displayIfNeeded()
}
private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) {
private static func scheduleAttachRetry(
_ webView: WKWebView,
panel: BrowserPanel,
to host: NSView,
coordinator: Coordinator,
generation: Int
) {
// Don't schedule multiple overlapping retries.
guard coordinator.attachRetryWorkItem == nil else { return }
@ -2510,18 +2850,54 @@ struct WebViewRepresentable: NSViewRepresentable {
// is in a window during bonsplit tree updates; moving the webview too early can be flaky.
guard host.window != nil else {
coordinator.attachRetryCount += 1
#if DEBUG
if coordinator.attachRetryCount == 1 || coordinator.attachRetryCount % 20 == 0 {
logDevToolsState(
panel,
event: "retry.waitingForWindow",
generation: generation,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: host)
)
}
#endif
// Be generous here: bonsplit structural updates can keep a representable
// container off-window longer than a few seconds under load.
if coordinator.attachRetryCount < 400 {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation)
scheduleAttachRetry(
webView,
panel: panel,
to: host,
coordinator: coordinator,
generation: generation
)
}
}
return
}
coordinator.attachRetryCount = 0
attachWebView(webView, to: host, coordinator: coordinator)
#if DEBUG
logDevToolsState(
panel,
event: "retry.attach.begin",
generation: generation,
retryCount: 0,
details: attachContext(webView: webView, host: host)
)
#endif
attachWebView(webView, to: host)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
logDevToolsState(
panel,
event: "retry.attached",
generation: generation,
retryCount: 0,
details: attachContext(webView: webView, host: host)
)
#endif
}
coordinator.attachRetryWorkItem = work
@ -2530,30 +2906,106 @@ struct WebViewRepresentable: NSViewRepresentable {
func updateNSView(_ nsView: NSView, context: Context) {
let webView = panel.webView
context.coordinator.panel = panel
context.coordinator.webView = webView
let shouldUseWindowPortal = panel.shouldPreserveWebViewAttachmentDuringTransientHide()
if shouldUseWindowPortal {
context.coordinator.usesWindowPortal = true
Self.clearPortalCallbacks(for: nsView)
updateUsingWindowPortal(nsView, context: context, webView: webView)
Self.applyFocus(
panel: panel,
webView: webView,
nsView: nsView,
shouldFocusWebView: shouldFocusWebView,
isPanelFocused: isPanelFocused
)
return
}
if context.coordinator.usesWindowPortal {
BrowserWindowPortalRegistry.detach(webView: webView)
context.coordinator.usesWindowPortal = false
context.coordinator.lastPortalHostId = nil
}
Self.clearPortalCallbacks(for: nsView)
// Bonsplit keepAllAlive keeps hidden tabs alive (opacity 0). WKWebView is fragile when left
// in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce
// WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane.
if !shouldAttachWebView {
// Split/layout churn can briefly create an off-window phase while DevTools is open.
// Detaching here can blank inspector content even when visibility preference stays true.
if nsView.window == nil,
webView.superview != nil,
panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.skipped.offWindowDevTools",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
return
}
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.beforeSync",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.afterSync",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
context.coordinator.attachRetryWorkItem?.cancel()
context.coordinator.attachRetryWorkItem = nil
context.coordinator.attachRetryCount = 0
context.coordinator.attachGeneration += 1
// Resign focus if WebKit currently owns first responder.
if let window = webView.window,
Self.responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
if let window = webView.window ?? nsView.window {
let state = Self.firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.resignFirstResponder",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags
)
#endif
window.makeFirstResponder(nil)
}
}
NSLayoutConstraint.deactivate(context.coordinator.constraints)
context.coordinator.constraints.removeAll()
if webView.superview != nil {
webView.removeFromSuperview()
}
nsView.subviews.forEach { $0.removeFromSuperview() }
#if DEBUG
Self.logDevToolsState(
panel,
event: "detach.done",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
return
}
@ -2563,17 +3015,83 @@ struct WebViewRepresentable: NSViewRepresentable {
context.coordinator.attachRetryWorkItem = nil
context.coordinator.attachGeneration += 1
if let window = webView.window ?? nsView.window {
let state = Self.firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.reparent.resignFirstResponder.begin",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags
)
#endif
let resigned = window.makeFirstResponder(nil)
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.reparent.resignFirstResponder.end",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView) + " " + state.flags + " resigned=\(resigned ? 1 : 0)"
)
#endif
}
}
if nsView.window == nil {
// Avoid attaching to off-window containers; during bonsplit structural updates SwiftUI
// can create containers that are never inserted into the window.
if panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "attach.defer.offWindow")
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.defer.requestRefresh",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
}
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.defer.offWindow",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
Self.scheduleAttachRetry(
webView,
panel: panel,
to: nsView,
coordinator: context.coordinator,
generation: context.coordinator.attachGeneration
)
} else {
Self.attachWebView(webView, to: nsView, coordinator: context.coordinator)
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.immediate.begin",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
Self.attachWebView(webView, to: nsView)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
Self.logDevToolsState(
panel,
event: "attach.immediate",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
}
} else {
// Already attached; no need for any pending retry.
@ -2581,25 +3099,59 @@ struct WebViewRepresentable: NSViewRepresentable {
context.coordinator.attachRetryWorkItem = nil
context.coordinator.attachRetryCount = 0
context.coordinator.attachGeneration += 1
let hadPendingRefresh = panel.hasPendingDeveloperToolsRefreshAfterAttach()
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
if hadPendingRefresh {
Self.logDevToolsState(
panel,
event: "attach.alreadyAttached.consumePendingRefresh",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
}
Self.logDevToolsState(
panel,
event: "attach.alreadyAttached",
generation: context.coordinator.attachGeneration,
retryCount: context.coordinator.attachRetryCount,
details: Self.attachContext(webView: webView, host: nsView)
)
#endif
}
Self.applyFocus(
panel: panel,
webView: webView,
nsView: nsView,
shouldFocusWebView: shouldFocusWebView,
isPanelFocused: isPanelFocused
)
}
private static func applyFocus(
panel: BrowserPanel,
webView: WKWebView,
nsView: NSView,
shouldFocusWebView: Bool,
isPanelFocused: Bool
) {
// Focus handling. Avoid fighting the address bar when it is focused.
guard let window = nsView.window else { return }
if shouldFocusWebView {
if panel.shouldSuppressWebViewFocus() {
return
}
if Self.responderChainContains(window.firstResponder, target: webView) {
if responderChainContains(window.firstResponder, target: webView) {
return
}
window.makeFirstResponder(webView)
} else {
} else if !isPanelFocused && responderChainContains(window.firstResponder, target: webView) {
// Only force-resign WebView focus when this panel itself is not focused.
// If the panel is focused but the omnibar-focus state is briefly stale, aggressively
// clearing first responder here can undo programmatic webview focus (socket tests).
if !isPanelFocused && Self.responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
}
window.makeFirstResponder(nil)
}
}
@ -2608,20 +3160,85 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.attachRetryWorkItem = nil
coordinator.attachRetryCount = 0
coordinator.attachGeneration += 1
NSLayoutConstraint.deactivate(coordinator.constraints)
coordinator.constraints.removeAll()
clearPortalCallbacks(for: nsView)
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel
if coordinator.usesWindowPortal {
coordinator.usesWindowPortal = false
coordinator.lastPortalHostId = nil
// During split/layout churn we keep the WKWebView portal-hosted so DevTools
// does not lose state. BrowserPanel deinit explicitly detaches on real teardown.
if let panel, panel.shouldPreserveWebViewAttachmentDuringTransientHide() {
#if DEBUG
logDevToolsState(
panel,
event: "dismantle.portal.keepAttached",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView)
)
#endif
return
}
BrowserWindowPortalRegistry.detach(webView: webView)
return
}
// If we're being torn down while the WKWebView (or one of its subviews) is first responder,
// resign it before detaching.
let window = webView.window ?? nsView.window
if let window, responderChainContains(window.firstResponder, target: webView) {
window.makeFirstResponder(nil)
if let window {
let state = firstResponderResignState(window.firstResponder, webView: webView)
if state.needsResign {
#if DEBUG
if let panel {
logDevToolsState(
panel,
event: "dismantle.resignFirstResponder",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView) + " " + state.flags
)
}
#endif
window.makeFirstResponder(nil)
}
}
// During split/layout churn, SwiftUI may tear down a host view while a new one is still
// coming online. When DevTools is intended open, avoid eagerly detaching here.
if let panel,
panel.shouldPreserveWebViewAttachmentDuringTransientHide(),
webView.superview === nsView {
#if DEBUG
logDevToolsState(
panel,
event: "dismantle.skipDetach.devTools",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView)
)
#endif
return
}
if webView.superview === nsView {
webView.removeFromSuperview()
#if DEBUG
if let panel {
logDevToolsState(
panel,
event: "dismantle.detached",
generation: coordinator.attachGeneration,
retryCount: coordinator.attachRetryCount,
details: attachContext(webView: webView, host: nsView)
)
}
#endif
}
}
}

View file

@ -7,6 +7,13 @@ import WebKit
/// the first responder.
final class CmuxWebView: WKWebView {
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
// route it through app/menu key equivalents, which can trigger unintended actions.
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
return false
}
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
return true
@ -110,10 +117,21 @@ final class CmuxWebView: WKWebView {
// of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because
// AppKit only bubbles up through superviews, not siblings.
//
// Fix: prevent WKWebView from registering as a drag destination entirely. AppKit won't
// route drags here, so they reach the SwiftUI overlay drop zones as intended.
// Fix: filter out text-based types that conflict with bonsplit tab drags, but keep
// file URL types so Finder file drops and HTML drag-and-drop work.
private static let blockedDragTypes: Set<NSPasteboard.PasteboardType> = [
.string, // public.utf8-plain-text matches bonsplit's NSString tab drags
NSPasteboard.PasteboardType("public.text"),
NSPasteboard.PasteboardType("public.plain-text"),
NSPasteboard.PasteboardType("com.splittabbar.tabtransfer"),
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
]
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
// No-op: suppress WKWebView's automatic drag type registration.
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
if !filtered.isEmpty {
super.registerForDraggedTypes(filtered)
}
}
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {

View file

@ -37,6 +37,7 @@ struct PanelContentView: View {
panel: browserPanel,
isFocused: isFocused,
isVisibleInUI: isVisibleInUI,
portalPriority: portalPriority,
onRequestPanelFocus: onRequestPanelFocus
)
}

View file

@ -39,6 +39,18 @@ enum NewWorkspacePlacement: String, CaseIterable, Identifiable {
}
}
enum WorkspaceAutoReorderSettings {
static let key = "workspaceAutoReorderOnNotification"
static let defaultValue = true
static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
if defaults.object(forKey: key) == nil {
return defaultValue
}
return defaults.bool(forKey: key)
}
}
enum WorkspacePlacementSettings {
static let placementKey = "newWorkspacePlacement"
static let defaultPlacement: NewWorkspacePlacement = .afterCurrent
@ -829,6 +841,16 @@ class TabManager: ObservableObject {
focusedBrowserPanel?.resetZoom() ?? false
}
@discardableResult
func toggleDeveloperToolsFocusedBrowser() -> Bool {
focusedBrowserPanel?.toggleDeveloperTools() ?? false
}
@discardableResult
func showJavaScriptConsoleFocusedBrowser() -> Bool {
focusedBrowserPanel?.showDeveloperToolsConsole() ?? false
}
/// Backwards compatibility: returns the focused surface ID
func focusedSurfaceId(for tabId: UUID) -> UUID? {
focusedPanelId(for: tabId)
@ -1253,6 +1275,28 @@ class TabManager: ObservableObject {
_ = newSplit(tabId: selectedTabId, surfaceId: focusedPanelId, direction: direction)
}
/// Create a new browser split from the currently focused panel.
@discardableResult
func createBrowserSplit(direction: SplitDirection, url: URL? = nil) -> UUID? {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }),
let focusedPanelId = tab.focusedPanelId else { return nil }
return newBrowserSplit(
tabId: selectedTabId,
fromPanelId: focusedPanelId,
orientation: direction.orientation,
insertFirst: direction.insertFirst,
url: url
)
}
/// Refresh Bonsplit right-side action button tooltips for all workspaces.
func refreshSplitButtonTooltips() {
for workspace in tabs {
workspace.refreshSplitButtonTooltips()
}
}
// MARK: - Pane Focus Navigation
/// Move focus to an adjacent pane in the specified direction
@ -1393,9 +1437,20 @@ class TabManager: ObservableObject {
// MARK: - Browser Panel Operations
/// Create a new browser panel in a split
func newBrowserSplit(tabId: UUID, fromPanelId: UUID, orientation: SplitOrientation, url: URL? = nil) -> UUID? {
func newBrowserSplit(
tabId: UUID,
fromPanelId: UUID,
orientation: SplitOrientation,
insertFirst: Bool = false,
url: URL? = nil
) -> UUID? {
guard let tab = tabs.first(where: { $0.id == tabId }) else { return nil }
return tab.newBrowserSplit(from: fromPanelId, orientation: orientation, url: url)?.id
return tab.newBrowserSplit(
from: fromPanelId,
orientation: orientation,
insertFirst: insertFirst,
url: url
)?.id
}
/// Create a new browser surface in a pane

View file

@ -461,6 +461,9 @@ class TerminalController {
case "reset_sidebar":
return resetSidebar(args)
case "read_screen":
return readScreenText(args)
#if DEBUG
case "set_shortcut":
@ -508,9 +511,6 @@ class TerminalController {
case "read_terminal_text":
return readTerminalText(args)
case "read_screen":
return readScreen(args)
case "render_stats":
return renderStats(args)
@ -679,6 +679,14 @@ class TerminalController {
return v2Result(id: id, self.v2WorkspaceMoveToWindow(params: params))
case "workspace.reorder":
return v2Result(id: id, self.v2WorkspaceReorder(params: params))
case "workspace.rename":
return v2Result(id: id, self.v2WorkspaceRename(params: params))
case "workspace.next":
return v2Result(id: id, self.v2WorkspaceNext(params: params))
case "workspace.previous":
return v2Result(id: id, self.v2WorkspacePrevious(params: params))
case "workspace.last":
return v2Result(id: id, self.v2WorkspaceLast(params: params))
// Surfaces / input
@ -708,6 +716,8 @@ class TerminalController {
return v2Result(id: id, self.v2SurfaceSendText(params: params))
case "surface.send_key":
return v2Result(id: id, self.v2SurfaceSendKey(params: params))
case "surface.clear_history":
return v2Result(id: id, self.v2SurfaceClearHistory(params: params))
case "surface.trigger_flash":
return v2Result(id: id, self.v2SurfaceTriggerFlash(params: params))
@ -720,6 +730,16 @@ class TerminalController {
return v2Result(id: id, self.v2PaneSurfaces(params: params))
case "pane.create":
return v2Result(id: id, self.v2PaneCreate(params: params))
case "pane.resize":
return v2Result(id: id, self.v2PaneResize(params: params))
case "pane.swap":
return v2Result(id: id, self.v2PaneSwap(params: params))
case "pane.break":
return v2Result(id: id, self.v2PaneBreak(params: params))
case "pane.join":
return v2Result(id: id, self.v2PaneJoin(params: params))
case "pane.last":
return v2Result(id: id, self.v2PaneLast(params: params))
// Notifications
case "notification.create":
@ -908,6 +928,8 @@ class TerminalController {
return v2Result(id: id, self.v2BrowserInputKeyboard(params: params))
case "browser.input_touch":
return v2Result(id: id, self.v2BrowserInputTouch(params: params))
case "surface.read_text":
return v2Result(id: id, self.v2SurfaceReadText(params: params))
#if DEBUG
@ -972,6 +994,10 @@ class TerminalController {
"workspace.close",
"workspace.move_to_window",
"workspace.reorder",
"workspace.rename",
"workspace.next",
"workspace.previous",
"workspace.last",
"surface.list",
"surface.current",
"surface.focus",
@ -985,11 +1011,18 @@ class TerminalController {
"surface.health",
"surface.send_text",
"surface.send_key",
"surface.read_text",
"surface.clear_history",
"surface.trigger_flash",
"pane.list",
"pane.focus",
"pane.surfaces",
"pane.create",
"pane.resize",
"pane.swap",
"pane.break",
"pane.join",
"pane.last",
"notification.create",
"notification.create_for_surface",
"notification.create_for_target",
@ -1695,6 +1728,116 @@ class TerminalController {
"index": v2OrNull(newIndex)
])
}
private func v2WorkspaceRename(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
guard let workspaceId = v2UUID(params, "workspace_id") else {
return .err(code: "invalid_params", message: "Missing or invalid workspace_id", data: nil)
}
guard let titleRaw = v2String(params, "title"),
!titleRaw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return .err(code: "invalid_params", message: "Missing or invalid title", data: nil)
}
let title = titleRaw.trimmingCharacters(in: .whitespacesAndNewlines)
var renamed = false
v2MainSync {
guard tabManager.tabs.contains(where: { $0.id == workspaceId }) else { return }
tabManager.setCustomTitle(tabId: workspaceId, title: title)
renamed = true
}
guard renamed else {
return .err(code: "not_found", message: "Workspace not found", data: [
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId)
])
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
return .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"title": title
])
}
private func v2WorkspaceNext(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
v2MainSync {
guard tabManager.selectedTabId != nil else { return }
if let windowId = v2ResolveWindowId(tabManager: tabManager) {
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
setActiveTabManager(tabManager)
}
tabManager.selectNextTab()
guard let workspaceId = tabManager.selectedTabId else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspacePrevious(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No workspace selected", data: nil)
v2MainSync {
guard tabManager.selectedTabId != nil else { return }
if let windowId = v2ResolveWindowId(tabManager: tabManager) {
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
setActiveTabManager(tabManager)
}
tabManager.selectPreviousTab()
guard let workspaceId = tabManager.selectedTabId else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2WorkspaceLast(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No previous workspace in history", data: nil)
v2MainSync {
guard let before = tabManager.selectedTabId else { return }
if let windowId = v2ResolveWindowId(tabManager: tabManager) {
_ = AppDelegate.shared?.focusMainWindow(windowId: windowId)
setActiveTabManager(tabManager)
}
tabManager.navigateBack()
guard let after = tabManager.selectedTabId, after != before else { return }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": after.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: after),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
// MARK: - V2 Surface Methods
@ -2399,6 +2542,155 @@ class TerminalController {
return result
}
private func v2SurfaceClearHistory(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to clear history", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
return
}
guard terminalPanel.performBindingAction("clear_screen") else {
result = .err(code: "not_supported", message: "clear_screen binding action is unavailable", data: nil)
return
}
terminalPanel.surface.forceRefresh()
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func v2SurfaceReadText(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var includeScrollback = v2Bool(params, "scrollback") ?? false
let lineLimit = v2Int(params, "lines")
if let lineLimit, lineLimit <= 0 {
return .err(code: "invalid_params", message: "lines must be greater than 0", data: nil)
}
if lineLimit != nil {
includeScrollback = true
}
var result: V2CallResult = .err(code: "internal_error", message: "Failed to read terminal text", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
result = .err(code: "not_found", message: "No focused surface", data: nil)
return
}
guard let terminalPanel = ws.terminalPanel(for: surfaceId) else {
result = .err(code: "invalid_params", message: "Surface is not a terminal", data: ["surface_id": surfaceId.uuidString])
return
}
let response = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
guard response.hasPrefix("OK ") else {
result = .err(code: "internal_error", message: response, data: nil)
return
}
let base64 = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
let decoded = Data(base64Encoded: base64).flatMap { String(data: $0, encoding: .utf8) }
guard let text = decoded ?? (base64.isEmpty ? "" : nil) else {
result = .err(code: "internal_error", message: "Failed to decode terminal text", data: nil)
return
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"text": text,
"base64": base64,
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId)
])
}
return result
}
private func readTerminalTextBase64(terminalPanel: TerminalPanel, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
guard let surface = terminalPanel.surface.surface else { return "ERROR: Terminal surface not found" }
let pointTag: ghostty_point_tag_e = includeScrollback ? GHOSTTY_POINT_SCREEN : GHOSTTY_POINT_VIEWPORT
let topLeft = ghostty_point_s(
tag: pointTag,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0
)
let bottomRight = ghostty_point_s(
tag: pointTag,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0
)
let selection = ghostty_selection_s(
top_left: topLeft,
bottom_right: bottomRight,
rectangle: true
)
var text = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &text) else {
return "ERROR: Failed to read terminal text"
}
defer {
ghostty_surface_free_text(surface, &text)
}
let rawData: Data
if let ptr = text.text, text.text_len > 0 {
rawData = Data(bytes: ptr, count: Int(text.text_len))
} else {
rawData = Data()
}
var output = String(decoding: rawData, as: UTF8.self)
if let lineLimit {
output = tailTerminalLines(output, maxLines: lineLimit)
}
let base64 = output.data(using: .utf8)?.base64EncodedString() ?? ""
return "OK \(base64)"
}
private func v2SurfaceTriggerFlash(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
@ -2626,6 +2918,269 @@ class TerminalController {
return result
}
private func v2PaneResize(params: [String: Any]) -> V2CallResult {
let direction = (v2String(params, "direction") ?? "").lowercased()
let amount = v2Int(params, "amount") ?? 1
guard ["left", "right", "up", "down"].contains(direction), amount > 0 else {
return .err(code: "invalid_params", message: "direction must be one of left|right|up|down and amount must be > 0", data: nil)
}
return .err(
code: "not_supported",
message: "pane.resize is not supported yet; Bonsplit does not currently expose a stable programmable divider API",
data: [
"direction": direction,
"amount": amount
]
)
}
private func v2PaneSwap(params: [String: Any]) -> V2CallResult {
guard let sourcePaneUUID = v2UUID(params, "pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid pane_id", data: nil)
}
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
}
if sourcePaneUUID == targetPaneUUID {
return .err(code: "invalid_params", message: "pane_id and target_pane_id must be different", data: nil)
}
let focus = v2Bool(params, "focus") ?? true
var result: V2CallResult = .err(code: "internal_error", message: "Failed to swap panes", data: nil)
v2MainSync {
guard let located = v2LocatePane(sourcePaneUUID) else {
result = .err(code: "not_found", message: "Source pane not found", data: ["pane_id": sourcePaneUUID.uuidString])
return
}
guard let targetPane = located.workspace.bonsplitController.allPaneIds.first(where: { $0.id == targetPaneUUID }) else {
result = .err(code: "not_found", message: "Target pane not found in source workspace", data: ["target_pane_id": targetPaneUUID.uuidString])
return
}
let workspace = located.workspace
let sourcePane = located.paneId
guard let selectedSourceTab = workspace.bonsplitController.selectedTab(inPane: sourcePane),
let selectedTargetTab = workspace.bonsplitController.selectedTab(inPane: targetPane),
let sourceSurfaceId = workspace.panelIdFromSurfaceId(selectedSourceTab.id),
let targetSurfaceId = workspace.panelIdFromSurfaceId(selectedTargetTab.id) else {
result = .err(code: "invalid_state", message: "Both panes must have a selected surface", data: nil)
return
}
// Keep pane identities stable during swap when one side has a single surface.
var sourcePlaceholder: UUID?
var targetPlaceholder: UUID?
if workspace.bonsplitController.tabs(inPane: sourcePane).count <= 1 {
sourcePlaceholder = workspace.newTerminalSurface(inPane: sourcePane, focus: false)?.id
if sourcePlaceholder == nil {
result = .err(code: "internal_error", message: "Failed to create source placeholder surface", data: nil)
return
}
}
if workspace.bonsplitController.tabs(inPane: targetPane).count <= 1 {
targetPlaceholder = workspace.newTerminalSurface(inPane: targetPane, focus: false)?.id
if targetPlaceholder == nil {
result = .err(code: "internal_error", message: "Failed to create target placeholder surface", data: nil)
return
}
}
guard workspace.moveSurface(panelId: sourceSurfaceId, toPane: targetPane, focus: false) else {
result = .err(code: "internal_error", message: "Failed moving source surface into target pane", data: nil)
return
}
guard workspace.moveSurface(panelId: targetSurfaceId, toPane: sourcePane, focus: false) else {
result = .err(code: "internal_error", message: "Failed moving target surface into source pane", data: nil)
return
}
if let sourcePlaceholder {
_ = workspace.closePanel(sourcePlaceholder, force: true)
}
if let targetPlaceholder {
_ = workspace.closePanel(targetPlaceholder, force: true)
}
if focus {
workspace.bonsplitController.focusPane(targetPane)
}
let windowId = located.windowId
result = .ok([
"window_id": windowId.uuidString,
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": workspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: workspace.id),
"pane_id": sourcePane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: sourcePane.id),
"target_pane_id": targetPane.id.uuidString,
"target_pane_ref": v2Ref(kind: .pane, uuid: targetPane.id),
"source_surface_id": sourceSurfaceId.uuidString,
"source_surface_ref": v2Ref(kind: .surface, uuid: sourceSurfaceId),
"target_surface_id": targetSurfaceId.uuidString,
"target_surface_ref": v2Ref(kind: .surface, uuid: targetSurfaceId)
])
}
return result
}
private func v2PaneBreak(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
let focus = v2Bool(params, "focus") ?? true
var result: V2CallResult = .err(code: "internal_error", message: "Failed to break pane", data: nil)
v2MainSync {
guard let sourceWorkspace = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let sourcePaneUUID = v2UUID(params, "pane_id")
let sourcePane: PaneID? = {
if let sourcePaneUUID {
return sourceWorkspace.bonsplitController.allPaneIds.first(where: { $0.id == sourcePaneUUID })
}
return sourceWorkspace.bonsplitController.focusedPaneId
}()
let surfaceId: UUID? = {
if let explicitSurface = v2UUID(params, "surface_id") { return explicitSurface }
if let sourcePane,
let selected = sourceWorkspace.bonsplitController.selectedTab(inPane: sourcePane) {
return sourceWorkspace.panelIdFromSurfaceId(selected.id)
}
return sourceWorkspace.focusedPanelId
}()
guard let surfaceId else {
result = .err(code: "not_found", message: "No source surface to break", data: nil)
return
}
guard sourceWorkspace.panels[surfaceId] != nil else {
result = .err(code: "not_found", message: "Surface not found", data: ["surface_id": surfaceId.uuidString])
return
}
let sourceIndex = sourceWorkspace.indexInPane(forPanelId: surfaceId)
let sourcePaneForRollback = sourceWorkspace.paneId(forPanelId: surfaceId)
guard let detached = sourceWorkspace.detachSurface(panelId: surfaceId) else {
result = .err(code: "internal_error", message: "Failed to detach source surface", data: nil)
return
}
let destinationWorkspace = tabManager.addWorkspace()
guard let destinationPane = destinationWorkspace.bonsplitController.focusedPaneId
?? destinationWorkspace.bonsplitController.allPaneIds.first else {
if let sourcePaneForRollback {
_ = sourceWorkspace.attachDetachedSurface(
detached,
inPane: sourcePaneForRollback,
atIndex: sourceIndex,
focus: true
)
}
result = .err(code: "internal_error", message: "Destination workspace has no pane", data: nil)
return
}
guard destinationWorkspace.attachDetachedSurface(detached, inPane: destinationPane, focus: focus) != nil else {
if let sourcePaneForRollback {
_ = sourceWorkspace.attachDetachedSurface(
detached,
inPane: sourcePaneForRollback,
atIndex: sourceIndex,
focus: true
)
}
result = .err(code: "internal_error", message: "Failed to attach surface to new workspace", data: nil)
return
}
if !focus {
tabManager.selectWorkspace(sourceWorkspace)
}
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": destinationWorkspace.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: destinationWorkspace.id),
"pane_id": destinationPane.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: destinationPane.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId)
])
}
return result
}
private func v2PaneJoin(params: [String: Any]) -> V2CallResult {
guard let targetPaneUUID = v2UUID(params, "target_pane_id") else {
return .err(code: "invalid_params", message: "Missing or invalid target_pane_id", data: nil)
}
var surfaceId = v2UUID(params, "surface_id")
if surfaceId == nil, let sourcePaneUUID = v2UUID(params, "pane_id") {
guard let sourceLocated = v2LocatePane(sourcePaneUUID),
let selected = sourceLocated.workspace.bonsplitController.selectedTab(inPane: sourceLocated.paneId),
let selectedSurface = sourceLocated.workspace.panelIdFromSurfaceId(selected.id) else {
return .err(code: "not_found", message: "Unable to resolve selected surface in source pane", data: [
"pane_id": sourcePaneUUID.uuidString
])
}
surfaceId = selectedSurface
}
guard let surfaceId else {
return .err(code: "invalid_params", message: "Missing surface_id (or pane_id with selected surface)", data: nil)
}
var moveParams: [String: Any] = [
"surface_id": surfaceId.uuidString,
"pane_id": targetPaneUUID.uuidString
]
if let focus = v2Bool(params, "focus") {
moveParams["focus"] = focus
}
return v2SurfaceMove(params: moveParams)
}
private func v2PaneLast(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
}
var result: V2CallResult = .err(code: "not_found", message: "No alternate pane available", data: nil)
v2MainSync {
guard let ws = v2ResolveWorkspace(params: params, tabManager: tabManager) else {
result = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
guard let focused = ws.bonsplitController.focusedPaneId else {
result = .err(code: "not_found", message: "No focused pane", data: nil)
return
}
guard let target = ws.bonsplitController.allPaneIds.first(where: { $0.id != focused.id }) else {
result = .err(code: "not_found", message: "No alternate pane available", data: nil)
return
}
ws.bonsplitController.focusPane(target)
let selectedSurfaceId = ws.bonsplitController.selectedTab(inPane: target).flatMap { ws.panelIdFromSurfaceId($0.id) }
let windowId = v2ResolveWindowId(tabManager: tabManager)
result = .ok([
"window_id": v2OrNull(windowId?.uuidString),
"window_ref": v2Ref(kind: .window, uuid: windowId),
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"pane_id": target.id.uuidString,
"pane_ref": v2Ref(kind: .pane, uuid: target.id),
"surface_id": v2OrNull(selectedSurfaceId?.uuidString),
"surface_ref": v2Ref(kind: .surface, uuid: selectedSurfaceId)
])
}
return result
}
// MARK: - V2 Notification Methods
private func v2NotificationCreate(params: [String: Any]) -> V2CallResult {
@ -6205,6 +6760,123 @@ class TerminalController {
}
#endif
private struct ReadScreenOptions {
let surfaceArg: String
let includeScrollback: Bool
let lineLimit: Int?
}
private struct ReadScreenParseError: Error {
let message: String
}
private func parseReadScreenArgs(_ args: String) -> Result<ReadScreenOptions, ReadScreenParseError> {
let tokens = args
.split(whereSeparator: { $0.isWhitespace })
.map(String.init)
var surfaceArg: String?
var includeScrollback = false
var lineLimit: Int?
var idx = 0
while idx < tokens.count {
let token = tokens[idx]
switch token {
case "--scrollback":
includeScrollback = true
idx += 1
case "--lines":
guard idx + 1 < tokens.count, let parsed = Int(tokens[idx + 1]), parsed > 0 else {
return .failure(ReadScreenParseError(message: "ERROR: --lines must be greater than 0"))
}
lineLimit = parsed
includeScrollback = true
idx += 2
default:
guard surfaceArg == nil else {
return .failure(ReadScreenParseError(message: "ERROR: Usage: read_screen [id|idx] [--scrollback] [--lines <n>]"))
}
surfaceArg = token
idx += 1
}
}
return .success(
ReadScreenOptions(
surfaceArg: surfaceArg ?? "",
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
)
}
private func tailTerminalLines(_ text: String, maxLines: Int) -> String {
guard maxLines > 0 else { return "" }
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
guard lines.count > maxLines else { return text }
return lines.suffix(maxLines).joined(separator: "\n")
}
private func readTerminalTextBase64(surfaceArg: String, includeScrollback: Bool = false, lineLimit: Int? = nil) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let trimmedSurfaceArg = surfaceArg.trimmingCharacters(in: .whitespacesAndNewlines)
var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
let panelId: UUID?
if trimmedSurfaceArg.isEmpty {
panelId = tab.focusedPanelId
} else {
panelId = resolveSurfaceId(from: trimmedSurfaceArg, tab: tab)
}
guard let panelId,
let terminalPanel = tab.terminalPanel(for: panelId) else {
result = "ERROR: Terminal surface not found"
return
}
result = readTerminalTextBase64(
terminalPanel: terminalPanel,
includeScrollback: includeScrollback,
lineLimit: lineLimit
)
}
return result
}
private func readScreenText(_ args: String) -> String {
let options: ReadScreenOptions
switch parseReadScreenArgs(args) {
case .success(let parsed):
options = parsed
case .failure(let error):
return error.message
}
let response = readTerminalTextBase64(
surfaceArg: options.surfaceArg,
includeScrollback: options.includeScrollback,
lineLimit: options.lineLimit
)
guard response.hasPrefix("OK ") else { return response }
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
if payload.isEmpty {
return ""
}
guard let data = Data(base64Encoded: payload) else {
return "ERROR: Failed to decode terminal text"
}
return String(decoding: data, as: UTF8.self)
}
private func helpText() -> String {
var text = """
Hierarchy: Workspace (sidebar tab) > Pane (split region) > Surface (nested tab) > Panel (terminal/browser)
@ -6237,6 +6909,7 @@ class TerminalController {
send_key <key> - Send special key (ctrl-c, ctrl-d, enter, tab, escape)
send_surface <id|idx> <text> - Send text to a specific terminal
send_key_surface <id|idx> <key> - Send special key to a specific terminal
read_screen [id|idx] [--scrollback] [--lines N] - Read terminal text (plain text)
Notification commands:
notify <title>|<subtitle>|<body> - Notify focused panel
@ -6298,7 +6971,6 @@ class TerminalController {
activate_app - Bring app + main window to front (test-only)
is_terminal_focused <id|idx> - Return true/false if terminal surface is first responder (test-only)
read_terminal_text [id|idx] - Read visible terminal text (base64, test-only)
read_screen [id|idx] - Read visible terminal text (plain text, legacy test-only)
render_stats [id|idx] - Read terminal render stats (draw counters, test-only)
layout_debug - Dump bonsplit layout + selected panel bounds (test-only)
bonsplit_underflow_count - Count bonsplit arranged-subview underflow events (test-only)
@ -6766,82 +7438,7 @@ class TerminalController {
}
private func readTerminalText(_ args: String) -> String {
guard let tabManager = tabManager else { return "ERROR: TabManager not available" }
let panelArg = args.trimmingCharacters(in: .whitespacesAndNewlines)
var result = "ERROR: No tab selected"
DispatchQueue.main.sync {
guard let tabId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == tabId }) else {
return
}
let panelId: UUID?
if panelArg.isEmpty {
panelId = tab.focusedPanelId
} else {
panelId = resolveSurfaceId(from: panelArg, tab: tab)
}
guard let panelId,
let terminalPanel = tab.terminalPanel(for: panelId),
let surface = terminalPanel.surface.surface else {
result = "ERROR: Terminal surface not found"
return
}
var selection = ghostty_selection_s(
top_left: ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_TOP_LEFT,
x: 0,
y: 0
),
bottom_right: ghostty_point_s(
tag: GHOSTTY_POINT_VIEWPORT,
coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT,
x: 0,
y: 0
),
rectangle: true
)
var text = ghostty_text_s()
guard ghostty_surface_read_text(surface, selection, &text) else {
result = "ERROR: Failed to read terminal text"
return
}
defer {
ghostty_surface_free_text(surface, &text)
}
let b64: String
if let ptr = text.text, text.text_len > 0 {
b64 = Data(bytes: ptr, count: Int(text.text_len)).base64EncodedString()
} else {
b64 = ""
}
result = "OK \(b64)"
}
return result
}
private func readScreen(_ args: String) -> String {
let response = readTerminalText(args)
guard response.hasPrefix("OK ") else { return response }
let payload = String(response.dropFirst(3)).trimmingCharacters(in: .whitespacesAndNewlines)
if payload.isEmpty {
return ""
}
guard let data = Data(base64Encoded: payload),
let text = String(data: data, encoding: .utf8) else {
return "ERROR: Failed to decode terminal text"
}
return text
readTerminalTextBase64(surfaceArg: args)
}
private struct RenderStatsResponse: Codable {

View file

@ -154,6 +154,10 @@ final class TerminalNotificationStore: ObservableObject {
return
}
if WorkspaceAutoReorderSettings.isEnabled() {
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
}
let notification = TerminalNotification(
id: UUID(),
tabId: tabId,

View file

@ -105,12 +105,22 @@ final class Workspace: Identifiable, ObservableObject {
// MARK: - Initialization
private static func currentSplitButtonTooltips() -> BonsplitConfiguration.SplitButtonTooltips {
BonsplitConfiguration.SplitButtonTooltips(
newTerminal: KeyboardShortcutSettings.Action.newSurface.tooltip("New Terminal"),
newBrowser: KeyboardShortcutSettings.Action.openBrowser.tooltip("New Browser"),
splitRight: KeyboardShortcutSettings.Action.splitRight.tooltip("Split Right"),
splitDown: KeyboardShortcutSettings.Action.splitDown.tooltip("Split Down")
)
}
private static func bonsplitAppearance(from config: GhosttyConfig) -> BonsplitConfiguration.Appearance {
bonsplitAppearance(from: config.backgroundColor)
}
private static func bonsplitAppearance(from backgroundColor: NSColor) -> BonsplitConfiguration.Appearance {
BonsplitConfiguration.Appearance(
splitButtonTooltips: Self.currentSplitButtonTooltips(),
enableAnimations: false,
chromeColors: .init(backgroundHex: backgroundColor.hexString())
)
@ -208,6 +218,12 @@ final class Workspace: Identifiable, ObservableObject {
}
}
func refreshSplitButtonTooltips() {
var configuration = bonsplitController.configuration
configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips()
bonsplitController.configuration = configuration
}
// MARK: - Surface ID to Panel ID Mapping
/// Mapping from bonsplit TabID (surface ID) to panel UUID
@ -571,11 +587,16 @@ final class Workspace: Identifiable, ObservableObject {
inPane paneId: PaneID,
url: URL? = nil,
focus: Bool? = nil,
insertAtEnd: Bool = false
insertAtEnd: Bool = false,
bypassInsecureHTTPHostOnce: String? = nil
) -> BrowserPanel? {
let shouldFocusNewTab = focus ?? (bonsplitController.focusedPaneId == paneId)
let browserPanel = BrowserPanel(workspaceId: id, initialURL: url)
let browserPanel = BrowserPanel(
workspaceId: id,
initialURL: url,
bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce
)
panels[browserPanel.id] = browserPanel
guard let newTabId = bonsplitController.createTab(

View file

@ -15,6 +15,12 @@ struct cmuxApp: App {
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey)
private var toggleBrowserDeveloperToolsShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
private var showBrowserJavaScriptConsoleShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
@ -426,6 +432,20 @@ struct cmuxApp: App {
}
.keyboardShortcut("r", modifiers: .command)
splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
if !manager.toggleDeveloperToolsFocusedBrowser() {
NSSound.beep()
}
}
splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) {
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
if !manager.showJavaScriptConsoleFocusedBrowser() {
NSSound.beep()
}
}
Button("Zoom In") {
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
}
@ -463,6 +483,14 @@ struct cmuxApp: App {
performSplitFromMenu(direction: .down)
}
splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) {
performBrowserSplitFromMenu(direction: .right)
}
splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) {
performBrowserSplitFromMenu(direction: .down)
}
Divider()
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
@ -545,6 +573,34 @@ struct cmuxApp: App {
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
}
private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut {
decodeShortcut(
from: toggleBrowserDeveloperToolsShortcutData,
fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
)
}
private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut {
decodeShortcut(
from: showBrowserJavaScriptConsoleShortcutData,
fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
)
}
private var splitBrowserRightMenuShortcut: StoredShortcut {
decodeShortcut(
from: splitBrowserRightShortcutData,
fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut
)
}
private var splitBrowserDownMenuShortcut: StoredShortcut {
decodeShortcut(
from: splitBrowserDownShortcutData,
fallback: KeyboardShortcutSettings.Action.splitBrowserDown.defaultShortcut
)
}
private var notificationMenuSnapshot: NotificationMenuSnapshot {
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
}
@ -577,6 +633,13 @@ struct cmuxApp: App {
tabManager.createSplit(direction: direction)
}
private func performBrowserSplitFromMenu(direction: SplitDirection) {
if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true {
return
}
_ = tabManager.createBrowserSplit(direction: direction)
}
@ViewBuilder
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
if let key = keyEquivalent(for: shortcut) {
@ -1132,6 +1195,7 @@ private enum DebugWindowConfigSnapshot {
"""
let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults)
let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
return """
# Sidebar Debug
@ -1142,6 +1206,9 @@ private enum DebugWindowConfigSnapshot {
# Menu Bar Extra Debug
\(menuBarPayload)
# Browser DevTools Button
\(browserDevToolsPayload)
"""
}
@ -1208,6 +1275,16 @@ private struct DebugWindowControlsView: View {
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0
@AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
@AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
private var selectedDevToolsIconOption: BrowserDevToolsIconOption {
BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon
}
private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption {
BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor
}
var body: some View {
ScrollView {
@ -1291,12 +1368,58 @@ private struct DebugWindowControlsView: View {
.padding(.top, 2)
}
GroupBox("Browser DevTools Button") {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 8) {
Text("Icon")
Picker("Icon", selection: $browserDevToolsIconNameRaw) {
ForEach(BrowserDevToolsIconOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
Spacer()
}
HStack(spacing: 8) {
Text("Color")
Picker("Color", selection: $browserDevToolsIconColorRaw) {
ForEach(BrowserDevToolsIconColorOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.labelsHidden()
.pickerStyle(.menu)
Spacer()
}
HStack(spacing: 8) {
Text("Preview")
Spacer()
Image(systemName: selectedDevToolsIconOption.rawValue)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(selectedDevToolsColorOption.color)
}
HStack(spacing: 12) {
Button("Reset Button") {
resetBrowserDevToolsButton()
}
Button("Copy Button Config") {
copyBrowserDevToolsButtonConfig()
}
}
}
.padding(.top, 2)
}
GroupBox("Copy") {
VStack(alignment: .leading, spacing: 8) {
Button("Copy All Debug Config") {
DebugWindowConfigSnapshot.copyCombinedToPasteboard()
}
Text("Copies sidebar, background, and menu bar debug settings as one payload.")
Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.")
.font(.caption)
.foregroundColor(.secondary)
}
@ -1357,6 +1480,18 @@ private struct DebugWindowControlsView: View {
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
private func resetBrowserDevToolsButton() {
browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue
browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue
}
private func copyBrowserDevToolsButtonConfig() {
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
private final class AboutWindowController: NSWindowController, NSWindowDelegate {
@ -2275,14 +2410,17 @@ struct SettingsView: View {
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@State private var shortcutResetToken = UUID()
@State private var topBlurOpacity: Double = 0
@State private var topBlurBaselineOffset: CGFloat?
@State private var settingsTitleLeadingInset: CGFloat = 92
@State private var showClearBrowserHistoryConfirmation = false
@State private var browserHistoryEntryCount: Int = 0
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
private var selectedWorkspacePlacement: NewWorkspacePlacement {
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
@ -2303,6 +2441,10 @@ struct SettingsView: View {
}
}
private var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
}
private func blurOpacity(forContentOffset offset: CGFloat) -> Double {
guard let baseline = topBlurBaselineOffset else { return 0 }
let reveal = (baseline - offset) / 24
@ -2343,6 +2485,17 @@ struct SettingsView: View {
SettingsCardDivider()
SettingsCardRow(
"Reorder on Notification",
subtitle: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions."
) {
Toggle("", isOn: $workspaceAutoReorder)
.labelsHidden()
.controlSize(.small)
}
SettingsCardDivider()
SettingsCardRow(
"Dock Badge",
subtitle: "Show unread count on app icon (Dock and Cmd+Tab)."
@ -2451,6 +2604,69 @@ struct SettingsView: View {
SettingsCardDivider()
VStack(alignment: .leading, spacing: 8) {
Text("HTTP Host Allowlist")
.font(.system(size: 13, weight: .semibold))
Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.")
.font(.caption)
.foregroundStyle(.secondary)
TextEditor(text: $browserInsecureHTTPAllowlistDraft)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.frame(minHeight: 86)
.padding(6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: .textBackgroundColor))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
)
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistField")
ViewThatFits(in: .horizontal) {
HStack(alignment: .center, spacing: 10) {
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: 0)
Button("Save") {
saveBrowserInsecureHTTPAllowlist()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
}
VStack(alignment: .leading, spacing: 8) {
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Spacer(minLength: 0)
Button("Save") {
saveBrowserInsecureHTTPAllowlist()
}
.buttonStyle(.bordered)
.controlSize(.small)
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
}
}
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
SettingsCardDivider()
SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) {
Button("Clear History…") {
showClearBrowserHistoryConfirmation = true
@ -2574,6 +2790,13 @@ struct SettingsView: View {
.onAppear {
BrowserHistoryStore.shared.loadIfNeeded()
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
}
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
// Keep draft in sync with external changes unless the user has local unsaved edits.
if browserInsecureHTTPAllowlistDraft == oldValue {
browserInsecureHTTPAllowlistDraft = newValue
}
}
.onReceive(BrowserHistoryStore.shared.$entries) { entries in
browserHistoryEntryCount = entries.count
@ -2599,11 +2822,18 @@ struct SettingsView: View {
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
KeyboardShortcutSettings.resetAll()
shortcutResetToken = UUID()
}
private func saveBrowserInsecureHTTPAllowlist() {
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
}
}
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {

View file

@ -1,6 +1,7 @@
import XCTest
import AppKit
import WebKit
import ObjectiveC.runtime
#if canImport(cmux_DEV)
@testable import cmux_DEV
@ -8,6 +9,49 @@ import WebKit
@testable import cmux
#endif
private var cmuxUnitTestInspectorAssociationKey: UInt8 = 0
private var cmuxUnitTestInspectorOverrideInstalled = false
private extension CmuxWebView {
@objc func cmuxUnitTestInspector() -> NSObject? {
objc_getAssociatedObject(self, &cmuxUnitTestInspectorAssociationKey) as? NSObject
}
}
private extension WKWebView {
func cmuxSetUnitTestInspector(_ inspector: NSObject?) {
objc_setAssociatedObject(
self,
&cmuxUnitTestInspectorAssociationKey,
inspector,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
}
private func installCmuxUnitTestInspectorOverride() {
guard !cmuxUnitTestInspectorOverrideInstalled else { return }
guard let replacementMethod = class_getInstanceMethod(
CmuxWebView.self,
#selector(CmuxWebView.cmuxUnitTestInspector)
) else {
fatalError("Unable to locate test inspector replacement method")
}
let added = class_addMethod(
CmuxWebView.self,
NSSelectorFromString("_inspector"),
method_getImplementation(replacementMethod),
method_getTypeEncoding(replacementMethod)
)
guard added else {
fatalError("Unable to install CmuxWebView _inspector test override")
}
cmuxUnitTestInspectorOverrideInstalled = true
}
final class CmuxWebViewKeyEquivalentTests: XCTestCase {
private final class ActionSpy: NSObject {
private(set) var invoked: Bool = false
@ -88,6 +132,258 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase {
}
}
final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {
private func makeIsolatedDefaults() -> UserDefaults {
let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
fatalError("Failed to create defaults suite")
}
defaults.removePersistentDomain(forName: suiteName)
addTeardownBlock {
defaults.removePersistentDomain(forName: suiteName)
}
return defaults
}
func testIconCatalogIncludesExpandedChoices() {
XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10)
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal))
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe))
XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare))
}
func testIconOptionFallsBackToDefaultForUnknownRawValue() {
let defaults = makeIsolatedDefaults()
defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
XCTAssertEqual(
BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults),
BrowserDevToolsButtonDebugSettings.defaultIcon
)
}
func testColorOptionFallsBackToDefaultForUnknownRawValue() {
let defaults = makeIsolatedDefaults()
defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
XCTAssertEqual(
BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults),
BrowserDevToolsButtonDebugSettings.defaultColor
)
}
func testCopyPayloadUsesPersistedValues() {
let defaults = makeIsolatedDefaults()
defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey)
defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey)
let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults)
XCTAssertTrue(payload.contains("browserDevToolsIconName=scope"))
XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive"))
}
}
final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase {
func testSafariDefaultShortcutForToggleDeveloperTools() {
let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut
XCTAssertEqual(shortcut.key, "i")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.option)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.control)
}
func testSafariDefaultShortcutForShowJavaScriptConsole() {
let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut
XCTAssertEqual(shortcut.key, "c")
XCTAssertTrue(shortcut.command)
XCTAssertTrue(shortcut.option)
XCTAssertFalse(shortcut.shift)
XCTAssertFalse(shortcut.control)
}
}
@MainActor
final class BrowserDeveloperToolsConfigurationTests: XCTestCase {
func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() {
let panel = BrowserPanel(workspaceId: UUID())
let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool
XCTAssertEqual(developerExtras, true)
if #available(macOS 13.3, *) {
XCTAssertTrue(panel.webView.isInspectable)
}
}
}
@MainActor
final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
private final class FakeInspector: NSObject {
private(set) var showCount = 0
private(set) var closeCount = 0
private var visible = false
@objc func isVisible() -> Bool {
visible
}
@objc func show() {
showCount += 1
visible = true
}
@objc func close() {
closeCount += 1
visible = false
}
}
override class func setUp() {
super.setUp()
installCmuxUnitTestInspectorOverride()
}
private func makePanelWithInspector() -> (BrowserPanel, FakeInspector) {
let panel = BrowserPanel(workspaceId: UUID())
let inspector = FakeInspector()
panel.webView.cmuxSetUnitTestInspector(inspector)
return (panel, inspector)
}
func testRestoreReopensInspectorAfterAttachWhenPreferredVisible() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
// Simulate WebKit closing inspector during detach/reattach churn.
inspector.close()
XCTAssertFalse(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.closeCount, 1)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 2)
}
func testSyncRespectsManualCloseAndPreventsUnexpectedRestore() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertEqual(inspector.showCount, 1)
// Simulate user closing inspector before detach.
inspector.close()
panel.syncDeveloperToolsPreferenceFromInspector()
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertFalse(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
}
func testSyncCanPreserveVisibleIntentDuringDetachChurn() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertEqual(inspector.showCount, 1)
// Simulate a transient close caused by view detach, not user intent.
inspector.close()
panel.syncDeveloperToolsPreferenceFromInspector(preserveVisibleIntent: true)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 2)
}
func testForcedRefreshAfterAttachKeepsVisibleInspectorState() {
let (panel, inspector) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.showCount, 1)
XCTAssertEqual(inspector.closeCount, 0)
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertTrue(panel.isDeveloperToolsVisible())
XCTAssertEqual(inspector.closeCount, 0)
XCTAssertEqual(inspector.showCount, 1)
// The force-refresh request should be one-shot.
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertEqual(inspector.closeCount, 0)
XCTAssertEqual(inspector.showCount, 1)
}
func testRefreshRequestTracksPendingStateUntilRestoreRuns() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
panel.requestDeveloperToolsRefreshAfterNextAttach(reason: "unit-test")
XCTAssertTrue(panel.hasPendingDeveloperToolsRefreshAfterAttach())
panel.restoreDeveloperToolsAfterAttachIfNeeded()
XCTAssertFalse(panel.hasPendingDeveloperToolsRefreshAfterAttach())
}
func testTransientHideAttachmentPreserveFollowsDeveloperToolsIntent() {
let (panel, _) = makePanelWithInspector()
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
XCTAssertTrue(panel.showDeveloperTools())
XCTAssertTrue(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
XCTAssertTrue(panel.hideDeveloperTools())
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
}
func testWebViewDismantleSkipsDetachWhenDeveloperToolsIntentIsVisible() {
let (panel, _) = makePanelWithInspector()
XCTAssertTrue(panel.showDeveloperTools())
let representable = WebViewRepresentable(
panel: panel,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100))
host.addSubview(panel.webView)
WebViewRepresentable.dismantleNSView(host, coordinator: coordinator)
XCTAssertTrue(panel.webView.superview === host)
}
func testWebViewDismantleDetachesWhenDeveloperToolsIntentIsHidden() {
let (panel, _) = makePanelWithInspector()
XCTAssertFalse(panel.shouldPreserveWebViewAttachmentDuringTransientHide())
let representable = WebViewRepresentable(
panel: panel,
shouldAttachWebView: true,
shouldFocusWebView: false,
isPanelFocused: true,
portalZPriority: 0
)
let coordinator = representable.makeCoordinator()
coordinator.webView = panel.webView
let host = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100))
host.addSubview(panel.webView)
WebViewRepresentable.dismantleNSView(host, coordinator: coordinator)
XCTAssertNil(panel.webView.superview)
}
}
final class WorkspaceShortcutMapperTests: XCTestCase {
func testCommandNineMapsToLastWorkspaceIndex() {
XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0)
@ -339,6 +635,43 @@ final class WorkspacePlacementSettingsTests: XCTestCase {
}
}
final class WorkspaceAutoReorderSettingsTests: XCTestCase {
func testDefaultIsEnabled() {
let suiteName = "WorkspaceAutoReorderSettingsTests.Default.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
}
func testDisabledWhenSetToFalse() {
let suiteName = "WorkspaceAutoReorderSettingsTests.Disabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(false, forKey: WorkspaceAutoReorderSettings.key)
XCTAssertFalse(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
}
func testEnabledWhenSetToTrue() {
let suiteName = "WorkspaceAutoReorderSettingsTests.Enabled.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: WorkspaceAutoReorderSettings.key)
XCTAssertTrue(WorkspaceAutoReorderSettings.isEnabled(defaults: defaults))
}
}
final class AppearanceSettingsTests: XCTestCase {
func testResolvedModeDefaultsToSystemWhenUnset() {
let suiteName = "AppearanceSettingsTests.Default.\(UUID().uuidString)"
@ -354,62 +687,56 @@ final class AppearanceSettingsTests: XCTestCase {
XCTAssertEqual(resolved, .system)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
}
func testResolvedModeMigratesLegacyAndInvalidValuesToSystem() {
let suiteName = "AppearanceSettingsTests.Migrate.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(AppearanceMode.auto.rawValue, forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
defaults.set("invalid-value", forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .system)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.system.rawValue)
}
func testResolvedModePreservesExplicitLightAndDark() {
let suiteName = "AppearanceSettingsTests.Preserve.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(AppearanceMode.light.rawValue, forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .light)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.light.rawValue)
defaults.set(AppearanceMode.dark.rawValue, forKey: AppearanceSettings.appearanceModeKey)
XCTAssertEqual(AppearanceSettings.resolvedMode(defaults: defaults), .dark)
XCTAssertEqual(defaults.string(forKey: AppearanceSettings.appearanceModeKey), AppearanceMode.dark.rawValue)
}
}
final class UpdateFeedResolverTests: XCTestCase {
final class UpdateChannelSettingsTests: XCTestCase {
func testDefaultNightlyPreferenceIsDisabled() {
XCTAssertFalse(UpdateChannelSettings.defaultIncludeNightlyBuilds)
}
func testResolvedFeedFallsBackToStableWhenInfoFeedMissing() {
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: nil)
XCTAssertEqual(resolved.url, UpdateFeedResolver.fallbackFeedURL)
let suiteName = "UpdateChannelSettingsTests.MissingInfo.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: nil, defaults: defaults)
XCTAssertEqual(resolved.url, UpdateChannelSettings.stableFeedURL)
XCTAssertFalse(resolved.isNightly)
XCTAssertTrue(resolved.usedFallback)
}
func testResolvedFeedUsesInfoFeedForStableChannel() {
let suiteName = "UpdateChannelSettingsTests.InfoFeed.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let infoFeed = "https://example.com/custom/appcast.xml"
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
let resolved = UpdateChannelSettings.resolvedFeedURLString(infoFeedURL: infoFeed, defaults: defaults)
XCTAssertEqual(resolved.url, infoFeed)
XCTAssertFalse(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
func testResolvedFeedDetectsNightlyChannelFromInfoFeed() {
let infoFeed = "https://example.com/nightly/appcast.xml"
let resolved = UpdateFeedResolver.resolvedFeedURLString(infoFeedURL: infoFeed)
XCTAssertEqual(resolved.url, infoFeed)
func testResolvedFeedUsesNightlyWhenPreferenceEnabled() {
let suiteName = "UpdateChannelSettingsTests.Nightly.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
defaults.set(true, forKey: UpdateChannelSettings.includeNightlyBuildsKey)
let resolved = UpdateChannelSettings.resolvedFeedURLString(
infoFeedURL: "https://example.com/custom/appcast.xml",
defaults: defaults
)
XCTAssertEqual(resolved.url, UpdateChannelSettings.nightlyFeedURL)
XCTAssertTrue(resolved.isNightly)
XCTAssertFalse(resolved.usedFallback)
}
@ -753,54 +1080,6 @@ final class SidebarDropPlannerTests: XCTestCase {
}
}
final class SidebarOutsideDropResetPolicyTests: XCTestCase {
func testOutsideDropResetsOnlyWhenDragIsActiveAndPayloadMatches() {
let tabId = UUID()
XCTAssertTrue(
SidebarOutsideDropResetPolicy.shouldResetDrag(
draggedTabId: tabId,
hasSidebarDragPayload: true
)
)
XCTAssertFalse(
SidebarOutsideDropResetPolicy.shouldResetDrag(
draggedTabId: nil,
hasSidebarDragPayload: true
)
)
XCTAssertFalse(
SidebarOutsideDropResetPolicy.shouldResetDrag(
draggedTabId: tabId,
hasSidebarDragPayload: false
)
)
}
}
final class SidebarDragFailsafePolicyTests: XCTestCase {
func testRequestsClearOnlyWhenDragIsActiveAndMouseIsUp() {
XCTAssertTrue(
SidebarDragFailsafePolicy.shouldRequestClear(
isDragActive: true,
isLeftMouseButtonDown: false
)
)
XCTAssertFalse(
SidebarDragFailsafePolicy.shouldRequestClear(
isDragActive: true,
isLeftMouseButtonDown: true
)
)
XCTAssertFalse(
SidebarDragFailsafePolicy.shouldRequestClear(
isDragActive: false,
isLeftMouseButtonDown: false
)
)
}
}
final class SidebarDragAutoScrollPlannerTests: XCTestCase {
func testAutoScrollPlanTriggersNearTopAndBottomOnly() {
let topPlan = SidebarDragAutoScrollPlanner.plan(distanceToTop: 4, distanceToBottom: 96, edgeInset: 44, minStep: 2, maxStep: 12)
@ -2033,22 +2312,6 @@ final class GhosttySurfaceOverlayTests: XCTestCase {
state = hostedView.debugInactiveOverlayState()
XCTAssertTrue(state.isHidden)
}
func testUnreadNotificationRingVisibilityTracksRequestedState() {
let hostedView = GhosttySurfaceScrollView(
surfaceView: GhosttyNSView(frame: NSRect(x: 0, y: 0, width: 80, height: 50))
)
hostedView.setNotificationRing(visible: true)
var state = hostedView.debugNotificationRingState()
XCTAssertFalse(state.isHidden)
XCTAssertEqual(state.opacity, 1, accuracy: 0.001)
hostedView.setNotificationRing(visible: false)
state = hostedView.debugNotificationRingState()
XCTAssertTrue(state.isHidden)
XCTAssertEqual(state.opacity, 0, accuracy: 0.001)
}
}
@MainActor
@ -2244,6 +2507,222 @@ final class TerminalWindowPortalLifecycleTests: XCTestCase {
}
}
@MainActor
final class BrowserWindowPortalLifecycleTests: XCTestCase {
private func realizeWindowLayout(_ window: NSWindow) {
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
window.contentView?.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
window.contentView?.layoutSubtreeIfNeeded()
}
func testPortalHostInstallsAboveContentViewForVisibility() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
let portal = WindowBrowserPortal(window: window)
_ = portal.webViewAtWindowPoint(NSPoint(x: 1, y: 1))
guard let contentView = window.contentView,
let container = contentView.superview else {
XCTFail("Expected content container")
return
}
guard let hostIndex = container.subviews.firstIndex(where: { $0 is WindowBrowserHostView }),
let contentIndex = container.subviews.firstIndex(where: { $0 === contentView }) else {
XCTFail("Expected host/content views in same container")
return
}
XCTAssertGreaterThan(
hostIndex,
contentIndex,
"Browser portal host must remain above content view so portal-hosted web views stay visible"
)
}
func testAnchorRebindKeepsWebViewInStablePortalSuperview() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor1 = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
let anchor2 = NSView(frame: NSRect(x: 240, y: 40, width: 180, height: 120))
contentView.addSubview(anchor1)
contentView.addSubview(anchor2)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor1, visibleInUI: true)
let firstSuperview = webView.superview
XCTAssertNotNil(firstSuperview)
XCTAssertTrue(firstSuperview is WindowBrowserSlotView)
portal.bind(webView: webView, to: anchor2, visibleInUI: true)
XCTAssertTrue(webView.superview === firstSuperview, "Anchor moves should not reparent the web view")
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor2)
guard let slot = webView.superview as? WindowBrowserSlotView,
let host = slot.superview as? WindowBrowserHostView else {
XCTFail("Expected browser slot + host views")
return
}
let expectedFrame = host.convert(anchor2.bounds, from: anchor2)
XCTAssertEqual(slot.frame.origin.x, expectedFrame.origin.x, accuracy: 0.5)
XCTAssertEqual(slot.frame.origin.y, expectedFrame.origin.y, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.width, expectedFrame.size.width, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.height, expectedFrame.size.height, accuracy: 0.5)
}
func testPortalClampsWebViewFrameToHostBoundsWhenAnchorOverflowsSidebar() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
// Simulate a transient oversized anchor rect during split churn.
let anchor = NSView(frame: NSRect(x: 120, y: 20, width: 260, height: 150))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected web view slot")
return
}
XCTAssertFalse(slot.isHidden, "Partially visible browser anchor should stay visible")
XCTAssertEqual(slot.frame.origin.x, 120, accuracy: 0.5)
XCTAssertEqual(slot.frame.origin.y, 20, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.width, 200, accuracy: 0.5)
XCTAssertEqual(slot.frame.size.height, 150, accuracy: 0.5)
}
func testPortalSyncNormalizesOutOfBoundsWebFrame() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 300),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 20, width: 220, height: 160))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
contentView.layoutSubtreeIfNeeded()
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView else {
XCTFail("Expected browser slot")
return
}
// Reproduce observed drift from logs where WebKit shifts/expands frame beyond slot bounds.
webView.frame = NSRect(x: 0, y: 250, width: slot.bounds.width, height: slot.bounds.height)
XCTAssertGreaterThan(webView.frame.maxY, slot.bounds.maxY)
portal.synchronizeWebViewForAnchor(anchor)
XCTAssertEqual(webView.frame.origin.x, slot.bounds.origin.x, accuracy: 0.5)
XCTAssertEqual(webView.frame.origin.y, slot.bounds.origin.y, accuracy: 0.5)
XCTAssertEqual(webView.frame.size.width, slot.bounds.size.width, accuracy: 0.5)
XCTAssertEqual(webView.frame.size.height, slot.bounds.size.height, accuracy: 0.5)
}
func testPortalHostBoundsBecomeReadyAfterBindingInFrameDrivenHierarchy() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 500, height: 320),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
let portal = WindowBrowserPortal(window: window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 40, y: 24, width: 220, height: 160))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
portal.bind(webView: webView, to: anchor, visibleInUI: true)
portal.synchronizeWebViewForAnchor(anchor)
guard let slot = webView.superview as? WindowBrowserSlotView,
let host = slot.superview as? WindowBrowserHostView else {
XCTFail("Expected portal slot + host views")
return
}
XCTAssertGreaterThan(host.bounds.width, 1, "Portal host width should be ready for clipping/sync")
XCTAssertGreaterThan(host.bounds.height, 1, "Portal host height should be ready for clipping/sync")
}
func testRegistryDetachRemovesPortalHostedWebView() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer { window.orderOut(nil) }
realizeWindowLayout(window)
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
let anchor = NSView(frame: NSRect(x: 20, y: 20, width: 180, height: 120))
contentView.addSubview(anchor)
let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration())
BrowserWindowPortalRegistry.bind(webView: webView, to: anchor, visibleInUI: true)
XCTAssertNotNil(webView.superview)
BrowserWindowPortalRegistry.detach(webView: webView)
XCTAssertNil(webView.superview)
}
}
final class BrowserLinkOpenSettingsTests: XCTestCase {
private var suiteName: String!
private var defaults: UserDefaults!

View file

@ -1,5 +1,7 @@
import XCTest
import Foundation
import AppKit
@testable import cmux_DEV
/// Regression test: ensures UpdatePill is never gated behind #if DEBUG in production code paths.
/// This prevents accidentally hiding the update UI in Release builds.
@ -64,3 +66,133 @@ final class UpdatePillReleaseVisibilityTests: XCTestCase {
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
}
}
/// Regression test: ensure WKWebView can load HTTP development URLs (e.g. *.localtest.me).
final class AppTransportSecurityTests: XCTestCase {
func testInfoPlistAllowsArbitraryLoadsInWebContent() throws {
let projectRoot = findProjectRoot()
let infoPlistURL = projectRoot.appendingPathComponent("Resources/Info.plist")
let data = try Data(contentsOf: infoPlistURL)
var format = PropertyListSerialization.PropertyListFormat.xml
let plist = try XCTUnwrap(
PropertyListSerialization.propertyList(from: data, options: [], format: &format) as? [String: Any]
)
let ats = try XCTUnwrap(plist["NSAppTransportSecurity"] as? [String: Any])
XCTAssertEqual(
ats["NSAllowsArbitraryLoadsInWebContent"] as? Bool,
true,
"Resources/Info.plist must allow HTTP loads in WKWebView for local dev hostnames."
)
}
private func findProjectRoot() -> URL {
var dir = URL(fileURLWithPath: #file).deletingLastPathComponent().deletingLastPathComponent()
for _ in 0..<10 {
let marker = dir.appendingPathComponent("GhosttyTabs.xcodeproj")
if FileManager.default.fileExists(atPath: marker.path) {
return dir
}
dir = dir.deletingLastPathComponent()
}
return URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
}
}
final class BrowserInsecureHTTPSettingsTests: XCTestCase {
func testDefaultAllowlistPatternsArePresent() {
XCTAssertEqual(
BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: nil),
["127.0.0.1", "localhost", "*.localtest.me"]
)
}
func testWildcardAndExactHostMatching() {
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("localhost", rawAllowlist: nil))
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("api.localtest.me", rawAllowlist: nil))
XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("neverssl.com", rawAllowlist: nil))
}
func testCustomAllowlistNormalizesAndDeduplicatesEntries() {
let raw = """
localhost
*.example.com
127.0.0.1
https://dev.internal:8080/path
*.example.com
"""
XCTAssertEqual(
BrowserInsecureHTTPSettings.normalizedAllowlistPatterns(rawValue: raw),
["localhost", "*.example.com", "127.0.0.1", "dev.internal"]
)
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("foo.example.com", rawAllowlist: raw))
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("dev.internal", rawAllowlist: raw))
XCTAssertFalse(BrowserInsecureHTTPSettings.isHostAllowed("example.net", rawAllowlist: raw))
}
func testBlockDecisionUsesAllowlistAndSchemeRules() throws {
let localURL = try XCTUnwrap(URL(string: "http://foo.localtest.me:3000"))
XCTAssertFalse(browserShouldBlockInsecureHTTPURL(localURL, rawAllowlist: nil))
let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com"))
XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil))
let httpsURL = try XCTUnwrap(URL(string: "https://neverssl.com"))
XCTAssertFalse(browserShouldBlockInsecureHTTPURL(httpsURL, rawAllowlist: nil))
}
func testOneTimeBypassIsConsumedAfterFirstNavigation() throws {
let insecureURL = try XCTUnwrap(URL(string: "http://neverssl.com"))
var bypassHostOnce: String? = "neverssl.com"
XCTAssertTrue(browserShouldConsumeOneTimeInsecureHTTPBypass(
insecureURL,
bypassHostOnce: &bypassHostOnce
))
XCTAssertNil(bypassHostOnce)
// Subsequent visits should prompt again unless host was saved.
XCTAssertFalse(browserShouldConsumeOneTimeInsecureHTTPBypass(
insecureURL,
bypassHostOnce: &bypassHostOnce
))
XCTAssertTrue(browserShouldBlockInsecureHTTPURL(insecureURL, rawAllowlist: nil))
}
func testAddAllowedHostPersistsToDefaultsAndUnblocksHTTP() throws {
let suiteName = "BrowserInsecureHTTPSettingsTests.Persist.\(UUID().uuidString)"
guard let defaults = UserDefaults(suiteName: suiteName) else {
XCTFail("Failed to create isolated UserDefaults suite")
return
}
defer { defaults.removePersistentDomain(forName: suiteName) }
let url = try XCTUnwrap(URL(string: "http://persist-me.test"))
XCTAssertTrue(browserShouldBlockInsecureHTTPURL(url, defaults: defaults))
BrowserInsecureHTTPSettings.addAllowedHost("persist-me.test", defaults: defaults)
let persisted = defaults.string(forKey: BrowserInsecureHTTPSettings.allowlistKey)
XCTAssertNotNil(persisted)
XCTAssertTrue(BrowserInsecureHTTPSettings.isHostAllowed("persist-me.test", defaults: defaults))
XCTAssertFalse(browserShouldBlockInsecureHTTPURL(url, defaults: defaults))
}
func testAllowlistSelectionPersistsForProceedAndOpenExternal() {
XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection(
response: .alertFirstButtonReturn,
suppressionEnabled: true
))
XCTAssertTrue(browserShouldPersistInsecureHTTPAllowlistSelection(
response: .alertSecondButtonReturn,
suppressionEnabled: true
))
XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection(
response: .alertThirdButtonReturn,
suppressionEnabled: true
))
XCTAssertFalse(browserShouldPersistInsecureHTTPAllowlistSelection(
response: .alertSecondButtonReturn,
suppressionEnabled: false
))
}
}

View file

@ -21,13 +21,21 @@ CACHE_ROOT="${CMUX_GHOSTTYKIT_CACHE_DIR:-$HOME/.cache/cmux/ghosttykit}"
CACHE_DIR="$CACHE_ROOT/$GHOSTTY_SHA"
CACHE_XCFRAMEWORK="$CACHE_DIR/GhosttyKit.xcframework"
LOCAL_XCFRAMEWORK="$PROJECT_DIR/ghostty/macos/GhosttyKit.xcframework"
LOCAL_SHA_STAMP="$LOCAL_XCFRAMEWORK/.ghostty_sha"
LOCK_DIR="$CACHE_ROOT/$GHOSTTY_SHA.lock"
mkdir -p "$CACHE_ROOT"
echo "==> Ghostty submodule commit: $GHOSTTY_SHA"
LOCK_TIMEOUT=300
LOCK_START=$SECONDS
while ! mkdir "$LOCK_DIR" 2>/dev/null; do
if (( SECONDS - LOCK_START > LOCK_TIMEOUT )); then
echo "==> Lock stale (>${LOCK_TIMEOUT}s), removing and retrying..."
rmdir "$LOCK_DIR" 2>/dev/null || rm -rf "$LOCK_DIR"
continue
fi
echo "==> Waiting for GhosttyKit cache lock for $GHOSTTY_SHA..."
sleep 1
done
@ -36,25 +44,34 @@ trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1 || true' EXIT
if [ -d "$CACHE_XCFRAMEWORK" ]; then
echo "==> Reusing cached GhosttyKit.xcframework"
else
if [ -d "$LOCAL_XCFRAMEWORK" ]; then
echo "==> Seeding cache from existing local GhosttyKit.xcframework"
# Only reuse local xcframework if its SHA stamp matches the current ghostty commit.
# Without this check, a stale build from a previous commit could be cached under
# the wrong SHA, producing ABI mismatches.
LOCAL_SHA=""
if [ -f "$LOCAL_SHA_STAMP" ]; then
LOCAL_SHA="$(cat "$LOCAL_SHA_STAMP")"
fi
if [ -d "$LOCAL_XCFRAMEWORK" ] && [ "$LOCAL_SHA" = "$GHOSTTY_SHA" ]; then
echo "==> Seeding cache from existing local GhosttyKit.xcframework (SHA matches)"
else
echo "==> Building GhosttyKit.xcframework (this may take a few minutes)..."
(
cd ghostty
zig build -Demit-xcframework=true -Doptimize=ReleaseFast
)
# Stamp the build output with the SHA it was built from
echo "$GHOSTTY_SHA" > "$LOCAL_SHA_STAMP"
fi
SRC_XCFRAMEWORK="$LOCAL_XCFRAMEWORK"
if [ ! -d "$SRC_XCFRAMEWORK" ]; then
echo "Error: GhosttyKit.xcframework not found at $SRC_XCFRAMEWORK"
if [ ! -d "$LOCAL_XCFRAMEWORK" ]; then
echo "Error: GhosttyKit.xcframework not found at $LOCAL_XCFRAMEWORK"
exit 1
fi
TMP_DIR="$(mktemp -d "$CACHE_ROOT/.ghosttykit-tmp.XXXXXX")"
mkdir -p "$CACHE_DIR"
cp -R "$SRC_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework"
cp -R "$LOCAL_XCFRAMEWORK" "$TMP_DIR/GhosttyKit.xcframework"
rm -rf "$CACHE_XCFRAMEWORK"
mv "$TMP_DIR/GhosttyKit.xcframework" "$CACHE_XCFRAMEWORK"
rmdir "$TMP_DIR"

View file

@ -10,6 +10,9 @@ Keep this workflow focused on existing debug windows and menu entries. Do not ad
## Workflow
1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`.
- Menu path in app: `Debug``Debug Windows` → window entry.
- The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`).
- Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu.
2. Keep these actions available in `Menu("Debug Windows")`:
- `Sidebar Debug…`
- `Background Debug…`

View file

@ -5,8 +5,8 @@ usage() {
cat <<'USAGE'
Usage: debug_windows_snapshot.sh [--domain <defaults-domain>] [--copy]
Collect Sidebar Debug, Background Debug, and Menu Bar Extra debug values from macOS defaults
and print a combined payload. Use --copy to also copy the payload to clipboard.
Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values
from macOS defaults and print a combined payload. Use --copy to also copy the payload.
Examples:
debug_windows_snapshot.sh
@ -118,13 +118,16 @@ menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingle
menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)"
legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')"
if [[ -n "$legacySingleDigitX" ]]; then
menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)"
menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)"
else
menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)"
fi
menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)"
menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)"
browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')"
browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)"
payload="$(cat <<PAYLOAD
# Defaults domain
$domain
@ -166,6 +169,10 @@ menubarDebugMultiDigitYOffset=$menubarDebugMultiDigitYOffset
menubarDebugSingleDigitXAdjust=$menubarDebugSingleDigitXAdjust
menubarDebugMultiDigitXAdjust=$menubarDebugMultiDigitXAdjust
menubarDebugTextRectWidthAdjust=$menubarDebugTextRectWidthAdjust
# Browser DevTools Button
browserDevToolsIconName=$browserDevToolsIconName
browserDevToolsIconColor=$browserDevToolsIconColor
PAYLOAD
)"

View file

@ -398,12 +398,43 @@ class cmux:
wsid = self._resolve_workspace_id(workspace)
self._call("workspace.select", {"workspace_id": wsid})
def rename_workspace(self, title: str, workspace: Union[str, int, None] = None) -> None:
renamed = str(title).strip()
if not renamed:
raise cmuxError("rename_workspace requires a non-empty title")
wsid = self._resolve_workspace_id(workspace)
params: Dict[str, Any] = {"title": renamed}
if wsid:
params["workspace_id"] = wsid
self._call("workspace.rename", params)
def current_workspace(self) -> str:
wsid = self._resolve_workspace_id(None)
if not wsid:
raise cmuxError("No current workspace")
return wsid
def next_workspace(self) -> str:
res = self._call("workspace.next") or {}
wsid = res.get("workspace_id")
if not wsid:
raise cmuxError(f"workspace.next returned no workspace_id: {res}")
return str(wsid)
def previous_workspace(self) -> str:
res = self._call("workspace.previous") or {}
wsid = res.get("workspace_id")
if not wsid:
raise cmuxError(f"workspace.previous returned no workspace_id: {res}")
return str(wsid)
def last_workspace(self) -> str:
res = self._call("workspace.last") or {}
wsid = res.get("workspace_id")
if not wsid:
raise cmuxError(f"workspace.last returned no workspace_id: {res}")
return str(wsid)
def move_workspace_to_window(self, workspace: Union[str, int], window_id: str, focus: bool = True) -> None:
wsid = self._resolve_workspace_id(workspace)
self._call(
@ -639,6 +670,18 @@ class cmux:
res = self._call("surface.health", params) or {}
return list(res.get("surfaces") or [])
def clear_history(self, surface: Union[str, int, None] = None, workspace: Union[str, int, None] = None) -> None:
params: Dict[str, Any] = {}
if workspace is not None:
wsid = self._resolve_workspace_id(workspace)
params["workspace_id"] = wsid
if surface is not None:
sid = self._resolve_surface_id(surface, workspace_id=params.get("workspace_id"))
if not sid:
raise cmuxError(f"Invalid surface: {surface!r}")
params["surface_id"] = sid
self._call("surface.clear_history", params)
# ---------------------------------------------------------------------
# Pane commands
# ---------------------------------------------------------------------
@ -677,6 +720,61 @@ class cmux:
))
return out
def swap_pane(self, pane: Union[str, int], target_pane: Union[str, int], focus: bool = True) -> None:
source = self._resolve_pane_id(pane)
target = self._resolve_pane_id(target_pane)
if not source or not target:
raise cmuxError(f"Invalid panes: pane={pane!r}, target_pane={target_pane!r}")
self._call("pane.swap", {"pane_id": source, "target_pane_id": target, "focus": bool(focus)})
def break_pane(self, pane: Union[str, int, None] = None, surface: Union[str, int, None] = None, focus: bool = True) -> str:
params: Dict[str, Any] = {"focus": bool(focus)}
if pane is not None:
pid = self._resolve_pane_id(pane)
if not pid:
raise cmuxError(f"Invalid pane: {pane!r}")
params["pane_id"] = pid
if surface is not None:
sid = self._resolve_surface_id(surface)
if not sid:
raise cmuxError(f"Invalid surface: {surface!r}")
params["surface_id"] = sid
res = self._call("pane.break", params) or {}
wsid = res.get("workspace_id")
if not wsid:
raise cmuxError(f"pane.break returned no workspace_id: {res}")
return str(wsid)
def join_pane(
self,
target_pane: Union[str, int],
pane: Union[str, int, None] = None,
surface: Union[str, int, None] = None,
focus: bool = True,
) -> None:
target = self._resolve_pane_id(target_pane)
if not target:
raise cmuxError(f"Invalid target_pane: {target_pane!r}")
params: Dict[str, Any] = {"target_pane_id": target, "focus": bool(focus)}
if pane is not None:
source = self._resolve_pane_id(pane)
if not source:
raise cmuxError(f"Invalid pane: {pane!r}")
params["pane_id"] = source
if surface is not None:
sid = self._resolve_surface_id(surface)
if not sid:
raise cmuxError(f"Invalid surface: {surface!r}")
params["surface_id"] = sid
self._call("pane.join", params)
def last_pane(self) -> str:
res = self._call("pane.last") or {}
pid = res.get("pane_id")
if not pid:
raise cmuxError(f"pane.last returned no pane_id: {res}")
return str(pid)
# ---------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------
@ -830,6 +928,18 @@ class cmux:
if panel is not None:
sid = self._resolve_surface_id(panel)
params["surface_id"] = sid
try:
res = self._call("surface.read_text", params) or {}
if "text" in res:
return str(res.get("text") or "")
b64 = str(res.get("base64") or "")
raw = base64.b64decode(b64) if b64 else b""
return raw.decode("utf-8", errors="replace")
except cmuxError as exc:
# Back-compat for older builds that only expose the debug method.
if "method_not_found" not in str(exc):
raise
res = self._call("debug.terminal.read_text", params) or {}
b64 = str(res.get("base64") or "")
raw = base64.b64decode(b64) if b64 else b""

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Regression: capture-pane parity via production read-screen APIs."""
import glob
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Callable, List
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None:
start = time.time()
while time.time() - start < timeout_s:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str]) -> str:
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout
def main() -> int:
cli = _find_cli_binary()
with cmux(SOCKET_PATH) as c:
caps = c.capabilities() or {}
methods = set(caps.get("methods") or [])
_must("surface.read_text" in methods, f"Missing surface.read_text in capabilities: {sorted(methods)[:20]}")
created_target = c._call("workspace.create") or {}
ws_target = str(created_target.get("workspace_id") or "")
_must(bool(ws_target), f"workspace.create returned no workspace_id: {created_target}")
c._call("workspace.select", {"workspace_id": ws_target})
surfaces_payload = c._call("surface.list", {"workspace_id": ws_target}) or {}
surfaces = surfaces_payload.get("surfaces") or []
_must(bool(surfaces), f"Expected at least one surface in workspace: {surfaces_payload}")
surface_target = str(surfaces[0].get("id") or "")
_must(bool(surface_target), f"surface.list returned surface without id: {surfaces_payload}")
created_other = c._call("workspace.create") or {}
ws_other = str(created_other.get("workspace_id") or "")
_must(bool(ws_other), f"workspace.create returned no workspace_id: {created_other}")
c._call("workspace.select", {"workspace_id": ws_other})
selected = c._call("workspace.current") or {}
_must(str(selected.get("workspace_id") or "") == ws_other, f"Expected selected workspace {ws_other}, got: {selected}")
token = f"CMUX_READ_SCREEN_{int(time.time() * 1000)}"
c._call("surface.send_text", {
"workspace_id": ws_target,
"surface_id": surface_target,
"text": f"echo {token}\n",
})
def has_token() -> bool:
payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {}
return token in str(payload.get("text") or "")
_wait_for(has_token, timeout_s=5.0)
read_payload = c._call("surface.read_text", {"workspace_id": ws_target, "surface_id": surface_target}) or {}
text = str(read_payload.get("text") or "")
_must(token in text, f"surface.read_text missing token {token!r}: {read_payload}")
ws_only_payload = c._call("surface.read_text", {"workspace_id": ws_target}) or {}
_must(token in str(ws_only_payload.get("text") or ""), f"surface.read_text workspace-only call missing token {token!r}: {ws_only_payload}")
cli_text = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target])
_must(token in cli_text, f"cmux read-screen output missing token {token!r}: {cli_text!r}")
cli_ws_only = _run_cli(cli, ["read-screen", "--workspace", ws_target])
_must(token in cli_ws_only, f"cmux read-screen --workspace output missing token {token!r}: {cli_ws_only!r}")
cli_text_scrollback = _run_cli(cli, ["read-screen", "--workspace", ws_target, "--surface", surface_target, "--scrollback", "--lines", "80"])
_must(token in cli_text_scrollback, f"cmux read-screen --scrollback output missing token {token!r}: {cli_text_scrollback!r}")
cli_json = _run_cli(cli, ["--json", "read-screen", "--workspace", ws_target, "--surface", surface_target])
payload = json.loads(cli_json or "{}")
_must(token in str(payload.get("text") or ""), f"cmux --json read-screen missing token {token!r}: {payload}")
invalid = subprocess.run(
[cli, "--socket", SOCKET_PATH, "read-screen", "--workspace", ws_target, "--surface", surface_target, "--lines", "0"],
capture_output=True,
text=True,
check=False,
)
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
_must(invalid.returncode != 0, "Expected read-screen --lines 0 to fail")
_must("--lines must be greater than 0" in invalid_output, f"Unexpected error for --lines 0: {invalid_output!r}")
print("PASS: production read-screen APIs expose capture-pane behavior")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Regression: tmux rename-window parity via workspace.rename + CLI aliases."""
import glob
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import List
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str]) -> str:
env = dict(os.environ)
# Keep this test deterministic when running from inside another cmux shell.
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc.stdout
def _workspace_title(c: cmux, workspace_id: str) -> str:
payload = c._call("workspace.list") or {}
for row in payload.get("workspaces") or []:
if str(row.get("id") or "") == workspace_id:
return str(row.get("title") or "")
raise cmuxError(f"workspace.list missing workspace {workspace_id}: {payload}")
def main() -> int:
cli = _find_cli_binary()
stamp = int(time.time() * 1000)
with cmux(SOCKET_PATH) as c:
caps = c.capabilities() or {}
methods = set(caps.get("methods") or [])
_must("workspace.rename" in methods, f"Missing workspace.rename in capabilities: {sorted(methods)[:30]}")
created = c._call("workspace.create") or {}
ws_id = str(created.get("workspace_id") or "")
_must(bool(ws_id), f"workspace.create returned no workspace_id: {created}")
c._call("workspace.select", {"workspace_id": ws_id})
api_title = f"tmux-api-{stamp}"
c.rename_workspace(api_title, workspace=ws_id)
_must(_workspace_title(c, ws_id) == api_title, "workspace.rename API did not update workspace title")
cli_title = f"tmux cli {stamp}"
_run_cli(cli, ["rename-workspace", "--workspace", ws_id, cli_title])
_must(_workspace_title(c, ws_id) == cli_title, "cmux rename-workspace did not update workspace title")
alias_title = f"tmux alias {stamp}"
_run_cli(cli, ["rename-window", "--workspace", ws_id, alias_title])
_must(_workspace_title(c, ws_id) == alias_title, "cmux rename-window did not update workspace title")
current_title = f"tmux current {stamp}"
_run_cli(cli, ["rename-window", current_title])
_must(
_workspace_title(c, ws_id) == current_title,
"cmux rename-window without --workspace should target current workspace",
)
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
invalid = subprocess.run(
[cli, "--socket", SOCKET_PATH, "rename-window", "--workspace", ws_id],
capture_output=True,
text=True,
check=False,
env=env,
)
invalid_output = f"{invalid.stdout}\n{invalid.stderr}"
_must(invalid.returncode != 0, "Expected rename-window without title to fail")
_must(
"rename-window requires a title" in invalid_output,
f"Unexpected error for rename-window without title: {invalid_output!r}",
)
print("PASS: tmux rename-window parity works via workspace.rename and CLI aliases")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Regression: tmux compatibility command matrix (implemented + explicit not-supported)."""
import glob
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Callable, List, Tuple
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmux, cmuxError
SOCKET_PATH = os.environ.get("CMUX_SOCKET", "/tmp/cmux-debug.sock")
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def _wait_for(pred: Callable[[], bool], timeout_s: float = 5.0, step_s: float = 0.05) -> None:
start = time.time()
while time.time() - start < timeout_s:
if pred():
return
time.sleep(step_s)
raise cmuxError("Timed out waiting for condition")
def _find_cli_binary() -> str:
env_cli = os.environ.get("CMUXTERM_CLI")
if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK):
return env_cli
fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux")
if os.path.isfile(fixed) and os.access(fixed, os.X_OK):
return fixed
candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True)
candidates += glob.glob("/tmp/cmux-*/Build/Products/Debug/cmux")
candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)]
if not candidates:
raise cmuxError("Could not locate cmux CLI binary; set CMUXTERM_CLI")
candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True)
return candidates[0]
def _run_cli(cli: str, args: List[str], *, expect_ok: bool = True) -> subprocess.CompletedProcess[str]:
env = dict(os.environ)
env.pop("CMUX_WORKSPACE_ID", None)
env.pop("CMUX_SURFACE_ID", None)
cmd = [cli, "--socket", SOCKET_PATH] + args
proc = subprocess.run(cmd, capture_output=True, text=True, check=False, env=env)
if expect_ok and proc.returncode != 0:
merged = f"{proc.stdout}\n{proc.stderr}".strip()
raise cmuxError(f"CLI failed ({' '.join(cmd)}): {merged}")
return proc
def _pane_selected_surface(c: cmux, pane_id: str) -> str:
rows = c.list_pane_surfaces(pane_id)
for _idx, sid, _title, selected in rows:
if selected:
return sid
if rows:
return rows[0][1]
raise cmuxError(f"pane {pane_id} has no surfaces")
def _pane_surface_ids(c: cmux, pane_id: str) -> List[str]:
rows = c.list_pane_surfaces(pane_id)
return [sid for _idx, sid, _title, _selected in rows]
def _surface_has(c: cmux, workspace_id: str, surface_id: str, token: str) -> bool:
payload = c._call("surface.read_text", {"workspace_id": workspace_id, "surface_id": surface_id, "scrollback": True}) or {}
return token in str(payload.get("text") or "")
def main() -> int:
cli = _find_cli_binary()
stamp = int(time.time() * 1000)
with cmux(SOCKET_PATH) as c:
caps = c.capabilities() or {}
methods = set(caps.get("methods") or [])
for method in [
"workspace.next",
"workspace.previous",
"workspace.last",
"pane.swap",
"pane.break",
"pane.join",
"pane.last",
"surface.clear_history",
]:
_must(method in methods, f"Missing capability {method!r}")
ws = c.new_workspace()
c.select_workspace(ws)
_ = c.new_split("right")
time.sleep(0.2)
panes = [pid for _pidx, pid, _count, _focused in c.list_panes()]
_must(len(panes) >= 2, f"Expected >=2 panes, got {panes}")
p1, p2 = panes[0], panes[1]
s1 = _pane_selected_surface(c, p1)
s2 = _pane_selected_surface(c, p2)
capture_token = f"TMUX_CAPTURE_{stamp}"
c.send_surface(s1, f"echo {capture_token}\n")
_wait_for(lambda: _surface_has(c, ws, s1, capture_token))
cap = _run_cli(cli, ["capture-pane", "--workspace", ws, "--surface", s1, "--scrollback"])
_must(capture_token in cap.stdout, f"capture-pane missing token: {cap.stdout!r}")
pipe_file = Path(tempfile.gettempdir()) / f"cmux_pipe_pane_{stamp}.log"
_run_cli(cli, ["pipe-pane", "--workspace", ws, "--surface", s1, "--command", f"cat > {pipe_file}"])
piped = pipe_file.read_text() if pipe_file.exists() else ""
_must(capture_token in piped, f"pipe-pane output missing token: {piped!r}")
wait_name = f"tmux_wait_{stamp}"
waiter = _run_cli(cli, ["wait-for", wait_name, "--timeout", "5"], expect_ok=False)
_must(waiter.returncode != 0, "wait-for without signal should time out when run synchronously in test")
signaler = subprocess.Popen(
[cli, "--socket", SOCKET_PATH, "wait-for", wait_name, "--timeout", "5"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={k: v for k, v in os.environ.items() if k not in {"CMUX_WORKSPACE_ID", "CMUX_SURFACE_ID"}},
)
time.sleep(0.2)
_run_cli(cli, ["wait-for", "-S", wait_name])
out, err = signaler.communicate(timeout=5)
_must(signaler.returncode == 0, f"wait-for signal/wait failed: out={out!r} err={err!r}")
title = f"tmux-title-{stamp}"
_run_cli(cli, ["rename-window", "--workspace", ws, title])
find = _run_cli(cli, ["find-window", title])
_must(title in find.stdout, f"find-window title search failed: {find.stdout!r}")
ws2 = c.new_workspace()
ws3 = c.new_workspace()
c.select_workspace(ws)
c.select_workspace(ws2)
_run_cli(cli, ["last-window"])
_must(c.current_workspace() == ws, f"last-window should navigate history back to ws={ws}")
_run_cli(cli, ["next-window"])
_must(c.current_workspace() == ws2, f"next-window should move to ws2={ws2}")
_run_cli(cli, ["previous-window"])
_must(c.current_workspace() == ws, f"previous-window should move back to ws={ws}")
c.select_workspace(ws)
pre_p1 = _pane_selected_surface(c, p1)
pre_p2 = _pane_selected_surface(c, p2)
_run_cli(cli, ["swap-pane", "--workspace", ws, "--pane", p1, "--target-pane", p2])
post_p1_ids = set(_pane_surface_ids(c, p1))
post_p2_ids = set(_pane_surface_ids(c, p2))
_must(pre_p2 in post_p1_ids, f"swap-pane should move target surface into source pane (p1={post_p1_ids}, pre_p2={pre_p2})")
_must(pre_p1 in post_p2_ids, f"swap-pane should move source surface into target pane (p2={post_p2_ids}, pre_p1={pre_p1})")
s_break = _pane_selected_surface(c, p1)
br = _run_cli(cli, ["--json", "--id-format", "both", "break-pane", "--workspace", ws, "--surface", s_break])
br_payload = json.loads(br.stdout or "{}")
ws_break = str(br_payload.get("workspace_id") or "")
_must(bool(ws_break), f"break-pane returned invalid payload: {br_payload}")
_must(ws_break in [wid for _idx, wid, _title, _sel in c.list_workspaces()], "break-pane workspace missing from list")
_run_cli(cli, ["join-pane", "--workspace", ws, "--surface", s_break, "--target-pane", p2])
_must(s_break in _pane_surface_ids(c, p2), f"join-pane should move broken surface into target pane {p2}")
current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()]
if len(current_panes) < 2:
_ = c.new_split("right")
time.sleep(0.2)
current_panes = [pid for _pidx, pid, _count, _focused in c.list_panes()]
_must(len(current_panes) >= 2, f"Expected >=2 panes after break/join, got {current_panes}")
lp_source, lp_target = current_panes[0], current_panes[1]
c.focus_pane(lp_source)
c.focus_pane(lp_target)
_run_cli(cli, ["last-pane", "--workspace", ws])
ident = c.identify()
focused = ident.get("focused") or {}
_must(
str(focused.get("pane_id") or "") == lp_source,
f"last-pane should focus previous pane {lp_source}, focused={focused}",
)
_run_cli(cli, ["clear-history", "--workspace", ws, "--surface", s1])
_run_cli(cli, ["set-hook", "workspace-created", "echo created"])
hooks = _run_cli(cli, ["set-hook", "--list"])
_must("workspace-created" in hooks.stdout, f"set-hook --list missing stored hook: {hooks.stdout!r}")
_run_cli(cli, ["set-hook", "--unset", "workspace-created"])
hooks2 = _run_cli(cli, ["set-hook", "--list"])
_must("workspace-created" not in hooks2.stdout, f"set-hook --unset failed: {hooks2.stdout!r}")
for cmd in (["popup"], ["bind-key", "C-b", "split-window"], ["unbind-key", "C-b"], ["copy-mode"]):
proc = _run_cli(cli, cmd, expect_ok=False)
merged = f"{proc.stdout}\n{proc.stderr}".lower()
_must(proc.returncode != 0 and "not supported" in merged, f"Expected not_supported for {cmd}, got: {merged!r}")
resize = _run_cli(cli, ["resize-pane", "--pane", lp_source, "-L", "--amount", "5"], expect_ok=False)
_must(resize.returncode != 0, "Expected resize-pane to return not_supported until backend support is added")
buffer_token = f"TMUX_BUFFER_{stamp}"
_run_cli(cli, ["set-buffer", "--name", "tmuxbuf", f"echo {buffer_token}\\n"])
buffers = _run_cli(cli, ["list-buffers"])
_must("tmuxbuf" in buffers.stdout, f"list-buffers missing tmuxbuf: {buffers.stdout!r}")
_run_cli(cli, ["paste-buffer", "--name", "tmuxbuf", "--workspace", ws, "--surface", s1])
_wait_for(lambda: _surface_has(c, ws, s1, buffer_token))
respawn_token = f"TMUX_RESPAWN_{stamp}"
_run_cli(cli, ["respawn-pane", "--workspace", ws, "--surface", s1, "--command", f"echo {respawn_token}"])
_wait_for(lambda: _surface_has(c, ws, s1, respawn_token))
msg = f"tmux-message-{stamp}"
shown = _run_cli(cli, ["display-message", "-p", msg])
_must(msg in shown.stdout, f"display-message -p should print message: {shown.stdout!r}")
print("PASS: tmux compatibility matrix commands are wired and tested")
return 0
if __name__ == "__main__":
raise SystemExit(main())

2
vendor/bonsplit vendored

@ -1 +1 @@
Subproject commit ae234a227cb77cc4f34e28098a565e987ca23d87
Subproject commit 6ac667d3a9c359b84f920eac4a2ffb027e3bf745

View file

@ -80,6 +80,16 @@ const CATEGORIES: ShortcutCategory[] = [
combos: [["⌥", "⌘", "←/→/↑/↓"]],
description: "Focus pane directionally",
},
{
id: "sp-browser-right",
combos: [["⌥", "⌘", "D"]],
description: "Split browser right",
},
{
id: "sp-browser-down",
combos: [["⌥", "⌘", "⇧", "D"]],
description: "Split browser down",
},
],
},
{
@ -88,8 +98,8 @@ const CATEGORIES: ShortcutCategory[] = [
shortcuts: [
{
id: "br-open",
combos: [["⌘", "⇧", "B"]],
description: "Open browser in split",
combos: [["⌘", "⇧", "L"]],
description: "Open browser surface",
},
{ id: "br-addr", combos: [["⌘", "L"]], description: "Focus address bar" },
{ id: "br-forward", combos: [["⌘", "]"]], description: "Forward" },