Merge branch 'main' of https://github.com/manaflow-ai/cmux into issue-915-terminal-not-loaded

This commit is contained in:
austinpower1258 2026-03-05 20:58:49 -08:00
commit 7af383c3d0
45 changed files with 5302 additions and 501 deletions

View file

@ -94,6 +94,9 @@ jobs:
sleep $((attempt * 5))
done
- name: Run issue #952 regression guard
run: python3 tests/test_issue_952_socket_listener_recovery.py
- name: Run unit tests
run: |
set -euo pipefail

View file

@ -122,6 +122,9 @@ jobs:
sleep $((attempt * 5))
done
- name: Run issue #952 regression guard
run: python3 tests/test_issue_952_socket_listener_recovery.py
- name: Run unit tests
run: |
set -euo pipefail
@ -244,6 +247,9 @@ jobs:
sleep $((attempt * 5))
done
- name: Run issue #952 regression guard
run: python3 tests/test_issue_952_socket_listener_recovery.py
- name: Create virtual display
run: |
set -euo pipefail

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View file

@ -122,6 +122,9 @@ jobs:
sleep $((attempt * 5))
done
- name: Run issue #952 regression guard
run: python3 tests/test_issue_952_socket_listener_recovery.py
- name: Run unit tests
if: ${{ !inputs.skip_unit_tests }}
run: |

View file

@ -34,10 +34,16 @@ When reporting a tagged reload result in chat, use the format for your agent typ
Never use `/tmp/cmux-<tag>/...` app links in chat output. If the expected DerivedData path is missing, resolve the real `.app` path and report that `file://` URL.
After making code changes, always run the build:
After making code changes, always use `reload.sh --tag` to build and launch. **Never run bare `xcodebuild` or `open` an untagged `cmux DEV.app`.** Untagged builds share the default debug socket and bundle ID with other agents, causing conflicts and stealing focus.
```bash
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' build
./scripts/reload.sh --tag <your-branch-slug>
```
If you only need to verify the build compiles (no launch), use a tagged derivedDataPath:
```bash
xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination 'platform=macOS' -derivedDataPath /tmp/cmux-<your-tag> build
```
When rebuilding GhosttyKit.xcframework, always use Release optimizations:
@ -107,6 +113,15 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- Focus events: `focus.panel`, `focus.bonsplit`, `focus.firstResponder`, `focus.moveFocus`
- Bonsplit events: `tab.select`, `tab.close`, `tab.dragStart`, `tab.drop`, `pane.focus`, `pane.drop`, `divider.dragStart`
## Regression test commit policy
When adding a regression test for a bug fix, use a two-commit structure so CI proves the test catches the bug:
1. **Commit 1:** Add the failing test only (no fix). CI should go red.
2. **Commit 2:** Add the fix. CI should go green.
This makes it visible in the GitHub PR UI (Commits tab, check statuses) that the test genuinely fails without the fix.
## Pitfalls
- **Custom UTTypes** for drag-and-drop must be declared in `Resources/Info.plist` under `UTExportedTypeDeclarations` (e.g. `com.splittabbar.tabtransfer`, `com.cmux.sidebar-tab-reorder`).
@ -115,6 +130,12 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- **Submodule safety:** When modifying a submodule (ghostty, vendor/bonsplit, etc.), always push the submodule commit to its remote `main` branch BEFORE committing the updated pointer in the parent repo. Never commit on a detached HEAD or temporary branch — the commit will be orphaned and lost. Verify with: `cd <submodule> && git merge-base --is-ancestor HEAD origin/main`.
- **All user-facing strings must be localized.** Use `String(localized: "key.name", defaultValue: "English text")` for every string shown in the UI (labels, buttons, menus, dialogs, tooltips, error messages). Keys go in `Resources/Localizable.xcstrings` with translations for all supported languages (currently English and Japanese). Never use bare string literals in SwiftUI `Text()`, `Button()`, alert titles, etc.
## Test quality policy
- Do not add tests that only verify source code text, method signatures, AST fragments, or grep-style patterns.
- Tests must verify observable runtime behavior through executable paths (unit/integration/e2e/CLI), not implementation shape.
- If a behavior cannot be exercised end-to-end yet, add a small runtime seam or harness first, then test through that seam.
## Socket command threading policy
- Do not use `DispatchQueue.main.sync` for high-frequency socket telemetry commands (`report_*`, `ports_kick`, status/progress/log metadata updates).
@ -131,21 +152,14 @@ tail -f "$(cat /tmp/cmux-last-debug-log-path 2>/dev/null || echo /tmp/cmux-debug
- Only explicit focus-intent commands may mutate in-app focus/selection (`window.focus`, `workspace.select/next/previous/last`, `surface.focus`, `pane.focus/last`, browser focus commands, and v1 focus equivalents).
- All non-focus commands should preserve current user focus context while still applying data/model changes.
## E2E mac UI tests
## Testing policy
Run UI tests on the UTM macOS VM (never on the host machine). Always run e2e UI tests via `ssh cmux-vm`:
**Never run tests locally.** All tests (E2E, UI, python socket tests) run via GitHub Actions or on the VM.
```bash
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" -only-testing:cmuxUITests/UpdatePillUITests test'
```
## Basic tests
Run basic automated tests on the UTM macOS VM (never on the host machine):
```bash
ssh cmux-vm 'cd /Users/cmux/GhosttyTabs && xcodebuild -project GhosttyTabs.xcodeproj -scheme cmux -configuration Debug -destination "platform=macOS" build && pkill -x "cmux DEV" || true && APP=$(find /Users/cmux/Library/Developer/Xcode/DerivedData -path "*/Build/Products/Debug/cmux DEV.app" -print -quit) && open "$APP" --env CMUX_SOCKET_MODE=allowAll && for i in {1..20}; do [ -S /tmp/cmux-debug.sock ] && break; sleep 0.5; done && python3 tests/test_update_timing.py && python3 tests/test_signals_auto.py && python3 tests/test_ctrl_socket.py && python3 tests/test_notifications.py'
```
- **E2E / UI tests:** trigger via `gh workflow run test-e2e.yml` (see cmuxterm-hq CLAUDE.md for details)
- **Unit tests:** `xcodebuild -scheme cmux-unit` is safe (no app launch), but prefer CI
- **Python socket tests (tests_v2/):** these connect to a running cmux instance's socket. Never launch an untagged `cmux DEV.app` to run them. If you must test locally, use a tagged build's socket (`/tmp/cmux-debug-<tag>.sock`) with `CMUX_SOCKET=/tmp/cmux-debug-<tag>.sock`
- **Never `open` an untagged `cmux DEV.app`** from DerivedData. It conflicts with the user's running debug instance.
## Ghostty submodule workflow

View file

@ -1736,6 +1736,45 @@ struct CMUXCLI {
return (cwd as NSString).appendingPathComponent(expanded)
}
private func sanitizedFilenameComponent(_ raw: String) -> String {
let sanitized = raw.replacingOccurrences(
of: #"[^\p{L}\p{N}._-]+"#,
with: "-",
options: .regularExpression
)
let trimmed = sanitized.trimmingCharacters(in: CharacterSet(charactersIn: "-."))
return trimmed.isEmpty ? "item" : trimmed
}
private func bestEffortPruneTemporaryFiles(
in directoryURL: URL,
keepingMostRecent maxCount: Int = 50,
maxAge: TimeInterval = 24 * 60 * 60
) {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}
let now = Date()
let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]),
values.isRegularFile == true else {
return nil
}
return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast)
}.sorted { $0.date > $1.date }
for (index, entry) in datedEntries.enumerated() {
if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge {
try? FileManager.default.removeItem(at: entry.url)
}
}
}
// MARK: - Markdown Commands
private func runMarkdownCommand(
@ -2623,6 +2662,29 @@ struct CMUXCLI {
}
}
func displaySnapshotText(_ payload: [String: Any]) -> String {
let snapshotText = (payload["snapshot"] as? String) ?? "Empty page"
guard snapshotText.contains("\n- (empty)") else {
return snapshotText
}
let url = ((payload["url"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let readyState = ((payload["ready_state"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
var lines = [snapshotText]
if !url.isEmpty {
lines.append("url: \(url)")
}
if !readyState.isEmpty {
lines.append("ready_state: \(readyState)")
}
if url.isEmpty || url == "about:blank" {
lines.append("hint: run 'cmux browser <surface> get url' to verify navigation")
}
return lines.joined(separator: "\n")
}
func displayBrowserValue(_ value: Any) -> String {
if let dict = value as? [String: Any],
let type = dict["__cmux_t"] as? String,
@ -2841,10 +2903,8 @@ struct CMUXCLI {
let payload = try client.sendV2(method: "browser.snapshot", params: params)
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
} else if let text = payload["snapshot"] as? String {
print(text)
} else {
print("Empty page")
print(displaySnapshotText(payload))
}
return
}
@ -3052,17 +3112,139 @@ struct CMUXCLI {
if subcommand == "screenshot" {
let sid = try requireSurface()
let (outPathOpt, _) = parseOption(subArgs, name: "--out")
let payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
if let outPathOpt,
let b64 = payload["png_base64"] as? String,
let data = Data(base64Encoded: b64) {
try data.write(to: URL(fileURLWithPath: outPathOpt))
let localJSONOutput = hasFlag(subArgs, name: "--json")
let outputAsJSON = jsonOutput || localJSONOutput
var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid])
func fileURL(fromPath rawPath: String) -> URL {
let resolvedPath = resolvePath(rawPath)
return URL(fileURLWithPath: resolvedPath).standardizedFileURL
}
if jsonOutput {
print(jsonString(formatIDs(payload, mode: idFormat)))
func writeScreenshot(_ data: Data, to destinationURL: URL) throws {
try FileManager.default.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try data.write(to: destinationURL, options: .atomic)
}
func hasText(_ value: String?) -> Bool {
guard let value else { return false }
return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var screenshotPath = payload["path"] as? String
var screenshotURL = payload["url"] as? String
func syncScreenshotLocationFields() {
if !hasText(screenshotPath),
let rawURL = screenshotURL,
let fileURL = URL(string: rawURL),
fileURL.isFileURL,
!fileURL.path.isEmpty {
screenshotPath = fileURL.path
}
if !hasText(screenshotURL),
let screenshotPath,
hasText(screenshotPath) {
screenshotURL = URL(fileURLWithPath: screenshotPath).standardizedFileURL.absoluteString
}
if let screenshotPath, hasText(screenshotPath) {
payload["path"] = screenshotPath
}
if let screenshotURL, hasText(screenshotURL) {
payload["url"] = screenshotURL
}
}
func persistPayloadScreenshot(to destinationURL: URL, allowFailure: Bool) throws -> Bool {
if let sourcePath = screenshotPath, hasText(sourcePath) {
let sourceURL = URL(fileURLWithPath: sourcePath).standardizedFileURL
do {
if sourceURL.path != destinationURL.path {
try FileManager.default.createDirectory(
at: destinationURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
try? FileManager.default.removeItem(at: destinationURL)
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
}
return true
} catch {
if payload["png_base64"] == nil {
if allowFailure {
return false
}
throw error
}
}
}
if let b64 = payload["png_base64"] as? String,
let data = Data(base64Encoded: b64) {
do {
try writeScreenshot(data, to: destinationURL)
return true
} catch {
if allowFailure {
return false
}
throw error
}
}
return false
}
if let outPathOpt {
let outputURL = fileURL(fromPath: outPathOpt)
guard try persistPayloadScreenshot(to: outputURL, allowFailure: false) else {
throw CLIError(message: "browser screenshot missing image data")
}
screenshotPath = outputURL.path
screenshotURL = outputURL.absoluteString
payload["path"] = screenshotPath
payload["url"] = screenshotURL
} else {
syncScreenshotLocationFields()
if !hasText(screenshotPath) && !hasText(screenshotURL) {
let outputDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-browser-screenshots-cli", isDirectory: true)
if (try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)) != nil {
bestEffortPruneTemporaryFiles(in: outputDir)
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
let safeSid = sanitizedFilenameComponent(sid)
let filename = "surface-\(safeSid)-\(timestampMs)-\(String(UUID().uuidString.prefix(8))).png"
let outputURL = outputDir.appendingPathComponent(filename, isDirectory: false)
if (try? persistPayloadScreenshot(to: outputURL, allowFailure: true)) == true {
screenshotPath = outputURL.path
screenshotURL = outputURL.absoluteString
payload["path"] = screenshotPath
payload["url"] = screenshotURL
}
}
}
}
if outputAsJSON {
let formattedPayload = formatIDs(payload, mode: idFormat)
if var outputPayload = formattedPayload as? [String: Any] {
if hasText(screenshotPath) || hasText(screenshotURL) {
outputPayload.removeValue(forKey: "png_base64")
}
print(jsonString(outputPayload))
} else {
print(jsonString(formattedPayload))
}
} else if let outPathOpt {
print("OK \(outPathOpt)")
} else if let screenshotURL,
hasText(screenshotURL) {
print("OK \(screenshotURL)")
} else if let screenshotPath,
hasText(screenshotPath) {
print("OK \(screenshotPath)")
} else {
print("OK")
}
@ -5511,8 +5693,10 @@ struct CMUXCLI {
}
private func jsonString(_ object: Any) -> String {
var options: JSONSerialization.WritingOptions = [.prettyPrinted]
options.insert(.withoutEscapingSlashes)
guard JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]),
let data = try? JSONSerialization.data(withJSONObject: object, options: options),
let output = String(data: data, encoding: .utf8) else {
return "{}"
}
@ -6797,6 +6981,7 @@ struct CMUXCLI {
browser press|keydown|keyup <key> [--snapshot-after]
browser select <selector> <value> [--snapshot-after]
browser scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after]
browser screenshot [--out <path>] [--json]
browser get <url|title|text|html|value|attr|count|box|styles> [...]
browser is <visible|enabled|checked> <selector>
browser find <role|text|label|placeholder|alt|title|testid|first|last|nth> ...

View file

@ -41,6 +41,9 @@ _CMUX_GIT_LAST_PWD="${_CMUX_GIT_LAST_PWD:-}"
_CMUX_GIT_LAST_RUN="${_CMUX_GIT_LAST_RUN:-0}"
_CMUX_GIT_JOB_PID="${_CMUX_GIT_JOB_PID:-}"
_CMUX_GIT_JOB_STARTED_AT="${_CMUX_GIT_JOB_STARTED_AT:-0}"
_CMUX_GIT_HEAD_LAST_PWD="${_CMUX_GIT_HEAD_LAST_PWD:-}"
_CMUX_GIT_HEAD_PATH="${_CMUX_GIT_HEAD_PATH:-}"
_CMUX_GIT_HEAD_SIGNATURE="${_CMUX_GIT_HEAD_SIGNATURE:-}"
_CMUX_PR_LAST_PWD="${_CMUX_PR_LAST_PWD:-}"
_CMUX_PR_LAST_RUN="${_CMUX_PR_LAST_RUN:-0}"
_CMUX_PR_JOB_PID="${_CMUX_PR_JOB_PID:-}"
@ -51,6 +54,41 @@ _CMUX_PORTS_LAST_RUN="${_CMUX_PORTS_LAST_RUN:-0}"
_CMUX_TTY_NAME="${_CMUX_TTY_NAME:-}"
_CMUX_TTY_REPORTED="${_CMUX_TTY_REPORTED:-0}"
_cmux_git_resolve_head_path() {
# Resolve the HEAD file path without invoking git (fast; works for worktrees).
local dir="$PWD"
while :; do
if [[ -d "$dir/.git" ]]; then
printf '%s\n' "$dir/.git/HEAD"
return 0
fi
if [[ -f "$dir/.git" ]]; then
local line gitdir
IFS= read -r line < "$dir/.git" || line=""
if [[ "$line" == gitdir:* ]]; then
gitdir="${line#gitdir:}"
gitdir="${gitdir## }"
gitdir="${gitdir%% }"
[[ -n "$gitdir" ]] || return 1
[[ "$gitdir" != /* ]] && gitdir="$dir/$gitdir"
printf '%s\n' "$gitdir/HEAD"
return 0
fi
fi
[[ "$dir" == "/" || -z "$dir" ]] && break
dir="$(dirname "$dir")"
done
return 1
}
_cmux_git_head_signature() {
local head_path="$1"
[[ -n "$head_path" && -r "$head_path" ]] || return 1
local line
IFS= read -r line < "$head_path" || return 1
printf '%s\n' "$line"
}
_cmux_report_tty_once() {
# Send the TTY name to the app once per session so the batched port scanner
# knows which TTY belongs to this panel.
@ -62,7 +100,7 @@ _cmux_report_tty_once() {
_CMUX_TTY_REPORTED=1
{
_cmux_send "report_tty $_CMUX_TTY_NAME --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
} >/dev/null 2>&1 & disown
}
_cmux_ports_kick() {
@ -74,7 +112,7 @@ _cmux_ports_kick() {
_CMUX_PORTS_LAST_RUN=$SECONDS
{
_cmux_send "ports_kick --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
} >/dev/null 2>&1 & disown
}
_cmux_prompt_command() {
@ -123,7 +161,26 @@ _cmux_prompt_command() {
{
local qpwd="${pwd//\"/\\\"}"
_cmux_send "report_pwd \"${qpwd}\" --tab=$CMUX_TAB_ID --panel=$CMUX_PANEL_ID"
} >/dev/null 2>&1 &
} >/dev/null 2>&1 & disown
fi
# Branch can change via aliases/tools while an older probe is still in flight.
# Track .git/HEAD content so we can restart stale probes immediately.
local git_head_changed=0
if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
_CMUX_GIT_HEAD_LAST_PWD="$pwd"
_CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
_CMUX_GIT_HEAD_SIGNATURE=""
fi
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
git_head_changed=1
# Also invalidate the PR probe so it refreshes with the new branch.
_CMUX_PR_LAST_RUN=0
fi
fi
# Git branch/dirty can change without a directory change (e.g. `git checkout`),
@ -131,7 +188,7 @@ _cmux_prompt_command() {
# When pwd changes (cd into a different repo), kill the old probe and start fresh
# so the sidebar picks up the new branch immediately.
if [[ -n "$_CMUX_GIT_JOB_PID" ]] && kill -0 "$_CMUX_GIT_JOB_PID" 2>/dev/null; then
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" ]]; then
if [[ "$pwd" != "$_CMUX_GIT_LAST_PWD" || "$git_head_changed" == "1" ]]; then
kill "$_CMUX_GIT_JOB_PID" >/dev/null 2>&1 || true
_CMUX_GIT_JOB_PID=""
_CMUX_GIT_JOB_STARTED_AT=0
@ -154,20 +211,21 @@ _cmux_prompt_command() {
fi
} >/dev/null 2>&1 &
_CMUX_GIT_JOB_PID=$!
disown
_CMUX_GIT_JOB_STARTED_AT=$now
fi
# Pull request metadata (number/state/url):
# refresh on cwd change and periodically to avoid stale status.
# refresh on cwd change, HEAD change, and periodically to avoid stale status.
if [[ -n "$_CMUX_PR_JOB_PID" ]] && kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]]; then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]]; then
kill "$_CMUX_PR_JOB_PID" >/dev/null 2>&1 || true
_CMUX_PR_JOB_PID=""
_CMUX_PR_JOB_STARTED_AT=0
fi
fi
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
if [[ "$pwd" != "$_CMUX_PR_LAST_PWD" || "$git_head_changed" == "1" ]] || (( now - _CMUX_PR_LAST_RUN >= 60 )); then
if [[ -z "$_CMUX_PR_JOB_PID" ]] || ! kill -0 "$_CMUX_PR_JOB_PID" 2>/dev/null; then
_CMUX_PR_LAST_PWD="$pwd"
_CMUX_PR_LAST_RUN=$now
@ -197,6 +255,7 @@ _cmux_prompt_command() {
fi
} >/dev/null 2>&1 &
_CMUX_PR_JOB_PID=$!
disown
_CMUX_PR_JOB_STARTED_AT=$now
fi
fi
@ -205,6 +264,7 @@ _cmux_prompt_command() {
if (( now - _CMUX_PORTS_LAST_RUN >= 10 )); then
_cmux_ports_kick
fi
}
_cmux_install_prompt_command() {

View file

@ -45,9 +45,8 @@ typeset -g _CMUX_GIT_JOB_STARTED_AT=0
typeset -g _CMUX_GIT_FORCE=0
typeset -g _CMUX_GIT_HEAD_LAST_PWD=""
typeset -g _CMUX_GIT_HEAD_PATH=""
typeset -g _CMUX_GIT_HEAD_MTIME=0
typeset -g _CMUX_GIT_HEAD_SIGNATURE=""
typeset -g _CMUX_GIT_HEAD_WATCH_PID=""
typeset -g _CMUX_HAVE_ZSTAT=0
typeset -g _CMUX_PR_LAST_PWD=""
typeset -g _CMUX_PR_LAST_RUN=0
typeset -g _CMUX_PR_JOB_PID=""
@ -60,19 +59,6 @@ typeset -g _CMUX_CMD_START=0
typeset -g _CMUX_TTY_NAME=""
typeset -g _CMUX_TTY_REPORTED=0
_cmux_ensure_zstat() {
# zstat is substantially cheaper than spawning external `stat`.
if (( _CMUX_HAVE_ZSTAT != 0 )); then
return 0
fi
if zmodload -F zsh/stat b:zstat 2>/dev/null; then
_CMUX_HAVE_ZSTAT=1
return 0
fi
_CMUX_HAVE_ZSTAT=-1
return 1
}
_cmux_git_resolve_head_path() {
# Resolve the HEAD file path without invoking git (fast; works for worktrees).
local dir="$PWD"
@ -100,27 +86,15 @@ _cmux_git_resolve_head_path() {
return 1
}
_cmux_git_head_mtime() {
_cmux_git_head_signature() {
local head_path="$1"
[[ -n "$head_path" && -f "$head_path" ]] || { print -r -- 0; return 0; }
if _cmux_ensure_zstat; then
typeset -A st
if zstat -H st +mtime -- "$head_path" 2>/dev/null; then
print -r -- "${st[mtime]:-0}"
return 0
fi
fi
# Fallback for environments where zsh/stat isn't available.
if command -v stat >/dev/null 2>&1; then
local mtime
mtime="$(stat -f %m "$head_path" 2>/dev/null || stat -c %Y "$head_path" 2>/dev/null || echo 0)"
print -r -- "$mtime"
[[ -n "$head_path" && -r "$head_path" ]] || return 1
local line=""
if IFS= read -r line < "$head_path"; then
print -r -- "$line"
return 0
fi
print -r -- 0
return 1
}
_cmux_report_tty_once() {
@ -184,23 +158,23 @@ _cmux_start_git_head_watch() {
watch_head_path="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
[[ -n "$watch_head_path" ]] || return 0
local watch_head_mtime
watch_head_mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)"
local watch_head_signature
watch_head_signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)"
_CMUX_GIT_HEAD_LAST_PWD="$watch_pwd"
_CMUX_GIT_HEAD_PATH="$watch_head_path"
_CMUX_GIT_HEAD_MTIME="$watch_head_mtime"
_CMUX_GIT_HEAD_SIGNATURE="$watch_head_signature"
_cmux_stop_git_head_watch
{
local last_mtime="$watch_head_mtime"
local last_signature="$watch_head_signature"
while true; do
sleep 1
local mtime
mtime="$(_cmux_git_head_mtime "$watch_head_path" 2>/dev/null || echo 0)"
if [[ -n "$mtime" && "$mtime" != 0 && "$mtime" != "$last_mtime" ]]; then
last_mtime="$mtime"
local signature
signature="$(_cmux_git_head_signature "$watch_head_path" 2>/dev/null || true)"
if [[ -n "$signature" && "$signature" != "$last_signature" ]]; then
last_signature="$signature"
_cmux_report_git_branch_for_path "$watch_pwd"
fi
done
@ -299,13 +273,13 @@ _cmux_precmd() {
if [[ "$pwd" != "$_CMUX_GIT_HEAD_LAST_PWD" ]]; then
_CMUX_GIT_HEAD_LAST_PWD="$pwd"
_CMUX_GIT_HEAD_PATH="$(_cmux_git_resolve_head_path 2>/dev/null || true)"
_CMUX_GIT_HEAD_MTIME=0
_CMUX_GIT_HEAD_SIGNATURE=""
fi
if [[ -n "$_CMUX_GIT_HEAD_PATH" ]]; then
local head_mtime
head_mtime="$(_cmux_git_head_mtime "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || echo 0)"
if [[ -n "$head_mtime" && "$head_mtime" != 0 && "$head_mtime" != "$_CMUX_GIT_HEAD_MTIME" ]]; then
_CMUX_GIT_HEAD_MTIME="$head_mtime"
local head_signature
head_signature="$(_cmux_git_head_signature "$_CMUX_GIT_HEAD_PATH" 2>/dev/null || true)"
if [[ -n "$head_signature" && "$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE" ]]; then
_CMUX_GIT_HEAD_SIGNATURE="$head_signature"
# Treat HEAD file change like a git command — force-replace any
# running probe so the sidebar picks up the new branch immediately.
_CMUX_GIT_FORCE=1

View file

@ -1005,14 +1005,31 @@ func shouldDispatchBrowserReturnViaFirstResponderKeyDown(
func shouldToggleMainWindowFullScreenForCommandControlFShortcut(
flags: NSEvent.ModifierFlags,
chars: String,
keyCode: UInt16
keyCode: UInt16,
layoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
) -> Bool {
let normalizedFlags = flags
.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function, .capsLock])
guard normalizedFlags == [.command, .control] else { return false }
let normalizedChars = chars.lowercased()
return normalizedChars == "f" || keyCode == 3
if normalizedChars == "f" {
return true
}
let charsAreControlSequence = !normalizedChars.isEmpty
&& normalizedChars.unicodeScalars.allSatisfy { CharacterSet.controlCharacters.contains($0) }
if !normalizedChars.isEmpty && !charsAreControlSequence {
return false
}
// Fallback to layout translation only when characters are unavailable (for
// synthetic/key-equivalent paths that can report an empty string).
if let translatedCharacter = layoutCharacterProvider(keyCode, flags), !translatedCharacter.isEmpty {
return translatedCharacter == "f"
}
// Keep ANSI fallback as a final safety net when layout translation is unavailable.
return keyCode == 3
}
func commandPaletteSelectionDeltaForKeyboardNavigation(
@ -1449,6 +1466,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
weak var sidebarState: SidebarState?
weak var fullscreenControlsViewModel: TitlebarControlsViewModel?
weak var sidebarSelectionState: SidebarSelectionState?
var shortcutLayoutCharacterProvider: (UInt16, NSEvent.ModifierFlags) -> String? = KeyboardLayout.character(forKeyCode:modifierFlags:)
private var workspaceObserver: NSObjectProtocol?
private var lifecycleSnapshotObservers: [NSObjectProtocol] = []
private var windowKeyObserver: NSObjectProtocol?
@ -1559,7 +1577,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var isApplyingStartupSessionRestore = false
private var sessionAutosaveTimer: DispatchSourceTimer?
private var socketListenerHealthTimer: DispatchSourceTimer?
private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(5)
private var socketListenerHealthCheckInFlight = false
private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2)
private var lastSocketListenerUnhealthyCaptureAt: Date = .distantPast
private static let socketListenerUnhealthyCaptureCooldown: TimeInterval = 60
private let sessionPersistenceQueue = DispatchQueue(
@ -1819,7 +1838,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: surfaceId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
@ -2491,25 +2509,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func stopSocketListenerHealthMonitor() {
socketListenerHealthTimer?.cancel()
socketListenerHealthTimer = nil
socketListenerHealthCheckInFlight = false
}
private func restartSocketListenerIfNeededForHealthCheck(source: String) {
guard let config = socketListenerConfigurationIfEnabled() else { return }
let health = TerminalController.shared.socketListenerHealth(expectedSocketPath: config.path)
guard !socketListenerHealthCheckInFlight,
let config = socketListenerConfigurationIfEnabled() else { return }
let expectedSocketPath = config.path
let terminalController = TerminalController.shared
socketListenerHealthCheckInFlight = true
Thread.detachNewThread { [weak self, expectedSocketPath, source, terminalController] in
let health = terminalController.socketListenerHealth(expectedSocketPath: expectedSocketPath)
Task { @MainActor [weak self, health] in
guard let self else { return }
self.socketListenerHealthCheckInFlight = false
self.handleSocketListenerHealthCheckResult(
health,
source: source,
expectedSocketPath: expectedSocketPath
)
}
}
}
private func handleSocketListenerHealthCheckResult(
_ health: TerminalController.SocketListenerHealth,
source: String,
expectedSocketPath: String
) {
guard let config = socketListenerConfigurationIfEnabled(),
config.path == expectedSocketPath else { return }
guard !health.isHealthy else {
lastSocketListenerUnhealthyCaptureAt = .distantPast
return
}
let failureSignals = health.failureSignals
let data: [String: Any] = [
var data: [String: Any] = [
"source": source,
"path": config.path,
"isRunning": health.isRunning ? 1 : 0,
"acceptLoopAlive": health.acceptLoopAlive ? 1 : 0,
"socketPathMatches": health.socketPathMatches ? 1 : 0,
"socketPathExists": health.socketPathExists ? 1 : 0,
"socketProbePerformed": health.socketProbePerformed ? 1 : 0,
"failureSignals": failureSignals
]
if let socketConnectable = health.socketConnectable {
data["socketConnectable"] = socketConnectable ? 1 : 0
}
if let socketConnectErrno = health.socketConnectErrno {
data["socketConnectErrno"] = Int(socketConnectErrno)
}
sentryBreadcrumb("socket.listener.unhealthy", category: "socket", data: data)
let now = Date()
if now.timeIntervalSince(lastSocketListenerUnhealthyCaptureAt) >= Self.socketListenerUnhealthyCaptureCooldown {
@ -5482,6 +5532,60 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
}
private func isGotoSplitUITestRecordingEnabled() -> Bool {
let env = ProcessInfo.processInfo.environment
return env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" || env["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] == "1"
}
private func gotoSplitUITestDataPath() -> String? {
guard isGotoSplitUITestRecordingEnabled() else { return nil }
let env = ProcessInfo.processInfo.environment
guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return nil }
return path
}
private func gotoSplitFindStateSnapshot(for workspace: Workspace) -> [String: String] {
var updates: [String: String] = [
"focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? ""
]
if let focusedPanelId = workspace.focusedPanelId {
updates["focusedPanelId"] = focusedPanelId.uuidString
if let terminal = workspace.terminalPanel(for: focusedPanelId) {
updates["focusedPanelKind"] = "terminal"
updates["focusedTerminalFindNeedle"] = terminal.searchState?.needle ?? ""
updates["focusedBrowserFindNeedle"] = ""
} else if let browser = workspace.browserPanel(for: focusedPanelId) {
updates["focusedPanelKind"] = "browser"
updates["focusedBrowserFindNeedle"] = browser.searchState?.needle ?? ""
updates["focusedTerminalFindNeedle"] = ""
} else {
updates["focusedPanelKind"] = "other"
updates["focusedTerminalFindNeedle"] = ""
updates["focusedBrowserFindNeedle"] = ""
}
} else {
updates["focusedPanelId"] = ""
updates["focusedPanelKind"] = "none"
updates["focusedTerminalFindNeedle"] = ""
updates["focusedBrowserFindNeedle"] = ""
}
let terminalWithFind = workspace.panels.values
.compactMap { $0 as? TerminalPanel }
.first(where: { $0.searchState != nil })
updates["terminalFindPanelId"] = terminalWithFind?.id.uuidString ?? ""
updates["terminalFindNeedle"] = terminalWithFind?.searchState?.needle ?? ""
let browserWithFind = workspace.panels.values
.compactMap { $0 as? BrowserPanel }
.first(where: { $0.searchState != nil })
updates["browserFindPanelId"] = browserWithFind?.id.uuidString ?? ""
updates["browserFindNeedle"] = browserWithFind?.searchState?.needle ?? ""
return updates
}
private func focusWebViewForGotoSplitUITest(tab: Workspace, browserPanelId: UUID, attempt: Int = 0) {
let maxAttempts = 120
guard attempt < maxAttempts else {
@ -5603,10 +5707,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
private func recordGotoSplitMoveIfNeeded(direction: NavigationDirection) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return }
guard let tabManager,
let focusedPaneId = tabManager.selectedWorkspace?.bonsplitController.focusedPaneId else { return }
guard isGotoSplitUITestRecordingEnabled() else { return }
guard let tabManager, let workspace = tabManager.selectedWorkspace else { return }
let directionValue: String
switch direction {
@ -5620,15 +5722,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
directionValue = "down"
}
writeGotoSplitTestData([
"lastMoveDirection": directionValue,
"focusedPaneId": focusedPaneId.description
])
var updates = gotoSplitFindStateSnapshot(for: workspace)
updates["lastMoveDirection"] = directionValue
writeGotoSplitTestData(updates)
}
private func recordGotoSplitSplitIfNeeded(direction: SplitDirection) {
let env = ProcessInfo.processInfo.environment
guard env["CMUX_UI_TEST_GOTO_SPLIT_SETUP"] == "1" else { return }
guard isGotoSplitUITestRecordingEnabled() else { return }
guard let workspace = tabManager?.selectedWorkspace else { return }
let directionValue: String
@ -5643,16 +5743,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
directionValue = "down"
}
writeGotoSplitTestData([
"lastSplitDirection": directionValue,
"paneCountAfterSplit": String(workspace.bonsplitController.allPaneIds.count),
"focusedPaneId": workspace.bonsplitController.focusedPaneId?.description ?? ""
])
var updates = gotoSplitFindStateSnapshot(for: workspace)
updates["lastSplitDirection"] = directionValue
updates["paneCountAfterSplit"] = String(workspace.bonsplitController.allPaneIds.count)
writeGotoSplitTestData(updates)
}
private func writeGotoSplitTestData(_ updates: [String: String]) {
let env = ProcessInfo.processInfo.environment
guard let path = env["CMUX_UI_TEST_GOTO_SPLIT_PATH"], !path.isEmpty else { return }
guard let path = gotoSplitUITestDataPath() else { return }
var payload = loadGotoSplitTestData(at: path)
for (key, value) in updates {
payload[key] = value
@ -5792,6 +5890,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
titlebarAccessoryController.toggleNotificationsPopover(animated: animated, anchorView: anchorView)
}
@discardableResult
func dismissNotificationsPopoverIfShown() -> Bool {
titlebarAccessoryController.dismissNotificationsPopoverIfShown()
}
func jumpToLatestUnread() {
guard let notificationStore else { return }
#if DEBUG
@ -6103,7 +6206,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private func handleCustomShortcut(event: NSEvent) -> Bool {
// `charactersIgnoringModifiers` can be nil for some synthetic NSEvents and certain special keys.
// Most shortcuts below use keyCode fallbacks, so treat nil as "" rather than bailing out.
// Treat nil as "" and rely on keyCode/layout-aware fallback logic where needed.
let chars = (event.charactersIgnoringModifiers ?? "").lowercased()
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
let hasControl = flags.contains(.control)
@ -6140,7 +6243,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// Special-case: Cmd+D should confirm destructive close on alerts.
// XCUITest key events often hit the app-level local monitor first, so forward the key
// equivalent to the alert panel explicitly.
if flags == [.command], chars == "d",
if matchShortcut(
event: event,
shortcut: StoredShortcut(key: "d", command: true, shift: false, option: false, control: false)
),
let root = closeConfirmationPanel.contentView,
let closeButton = findButton(in: root, titled: "Close") {
closeButton.performClick(nil)
@ -6315,15 +6421,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// focused omnibar in another window does not suppress Cmd+P here.
let hasFocusedAddressBarInShortcutContext = focusedBrowserAddressBarPanelIdForShortcutEvent(event) != nil
let isCommandP = !hasFocusedAddressBarInShortcutContext
&& normalizedFlags == [.command]
&& (chars == "p" || event.keyCode == 35)
&& matchShortcut(
event: event,
shortcut: StoredShortcut(key: "p", command: true, shift: false, option: false, control: false)
)
if isCommandP {
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
requestCommandPaletteSwitcher(preferredWindow: targetWindow, source: "shortcut.cmdP")
return true
}
let isCommandShiftP = normalizedFlags == [.command, .shift] && (chars == "p" || event.keyCode == 35)
let isCommandShiftP = matchShortcut(
event: event,
shortcut: StoredShortcut(key: "p", command: true, shift: true, option: false, control: false)
)
if isCommandShiftP {
let targetWindow = commandPaletteTargetWindow ?? event.window ?? NSApp.keyWindow ?? NSApp.mainWindow
requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.cmdShiftP")
@ -6339,11 +6450,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
if normalizedFlags == [.command], chars == "q" {
if matchShortcut(
event: event,
shortcut: StoredShortcut(key: "q", command: true, shift: false, option: false, control: false)
) {
return handleQuitShortcutWarning()
}
if normalizedFlags == [.command, .shift],
(chars == "," || chars == "<" || event.keyCode == 43) {
if matchShortcut(
event: event,
shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false)
) {
GhosttyApp.shared.reloadConfiguration(source: "shortcut.cmd_shift_comma")
return true
}
@ -6563,7 +6679,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
if normalizedFlags == [.command, .option], (chars == "t" || event.keyCode == 17) {
if matchShortcut(
event: event,
shortcut: StoredShortcut(key: "t", command: true, shift: false, option: true, control: false)
) {
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
targetWindow.identifier?.rawValue == "cmux.settings" {
targetWindow.performClose(nil)
@ -6584,7 +6703,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
// Cmd+W must close the focused panel even if first-responder momentarily lags on a
// browser NSTextView during split focus transitions.
if normalizedFlags == [.command], (chars == "w" || event.keyCode == 13) {
if matchShortcut(
event: event,
shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false)
) {
if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow,
targetWindow.identifier?.rawValue == "cmux.settings" {
targetWindow.performClose(nil)
@ -6813,7 +6935,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
// Focus browser address bar: Cmd+L
if flags == [.command] && chars == "l" {
if matchShortcut(
event: event,
shortcut: StoredShortcut(key: "l", command: true, shift: false, option: false, control: false)
) {
if let focusedPanel = tabManager?.focusedBrowserPanel {
focusBrowserAddressBar(in: focusedPanel)
return true
@ -7401,42 +7526,127 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return false
}
/// Match a shortcut against an event, handling normal keys
/// Match a shortcut against an event, handling normal keys.
private func matchShortcut(event: NSEvent, shortcut: StoredShortcut) -> Bool {
// Some keys can include extra flags (e.g. .function) depending on the responder chain.
// Strip those for consistent matching across first responders (terminal, WebKit, etc).
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
.subtracting([.numericPad, .function])
.subtracting([.numericPad, .function, .capsLock])
guard flags == shortcut.modifierFlags else { return false }
// NSEvent.charactersIgnoringModifiers preserves Shift for some symbol keys
// (e.g. Shift+] can yield "}" instead of "]"), so match brackets by keyCode.
let shortcutKey = shortcut.key.lowercased()
if shortcutKey == "\r" {
return event.keyCode == 36 || event.keyCode == 76
}
if shortcutKey == "[" || shortcutKey == "]" {
switch event.keyCode {
case 33: // kVK_ANSI_LeftBracket
return shortcutKey == "["
case 30: // kVK_ANSI_RightBracket
return shortcutKey == "]"
default:
return false
}
}
// Control-key combos can produce control characters (e.g. Ctrl+H => backspace),
// so fall back to keyCode matching for common printable keys.
if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == shortcutKey {
let eventCharsIgnoringModifiers = event.charactersIgnoringModifiers
if shortcutCharacterMatches(
eventCharacter: eventCharsIgnoringModifiers,
shortcutKey: shortcutKey,
applyShiftSymbolNormalization: flags.contains(.shift),
eventKeyCode: event.keyCode
) {
return true
}
if let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) {
// For command-based shortcuts, trust AppKit's layout-aware characters when present.
// Keep this strict for letter shortcuts to avoid physical-key collisions across layouts,
// while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts.
let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true)
if hasEventChars,
flags.contains(.command),
!flags.contains(.control),
shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) {
return false
}
// Match using the current keyboard layout so Command shortcuts stay character-based
// across layouts (QWERTY, Dvorak, etc.) instead of being tied to ANSI physical keys.
let layoutCharacter = shortcutLayoutCharacterProvider(event.keyCode, event.modifierFlags)
if shortcutCharacterMatches(
eventCharacter: layoutCharacter,
shortcutKey: shortcutKey,
applyShiftSymbolNormalization: false,
eventKeyCode: event.keyCode
) {
return true
}
// Control-key combos can surface as ASCII control characters (e.g. Ctrl+H => backspace),
// so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for
// command punctuation shortcuts, since some non-US layouts report different characters
// for the same physical key even when menu-equivalent semantics should still apply.
let allowANSIKeyCodeFallback = flags.contains(.control)
|| (flags.contains(.command)
&& !flags.contains(.control)
&& (
!shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey)
|| (!hasEventChars && (layoutCharacter?.isEmpty ?? true))
))
if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) {
return event.keyCode == expectedKeyCode
}
return false
}
private func shouldRequireCharacterMatchForCommandShortcut(shortcutKey: String) -> Bool {
guard shortcutKey.count == 1, let scalar = shortcutKey.unicodeScalars.first else {
return false
}
return CharacterSet.letters.contains(scalar)
}
private func shortcutCharacterMatches(
eventCharacter: String?,
shortcutKey: String,
applyShiftSymbolNormalization: Bool,
eventKeyCode: UInt16
) -> Bool {
guard let eventCharacter, !eventCharacter.isEmpty else { return false }
if normalizedShortcutEventCharacter(
eventCharacter,
applyShiftSymbolNormalization: applyShiftSymbolNormalization,
eventKeyCode: eventKeyCode
) == shortcutKey {
return true
}
return false
}
private func normalizedShortcutEventCharacter(
_ eventCharacter: String,
applyShiftSymbolNormalization: Bool,
eventKeyCode: UInt16
) -> String {
let lowered = eventCharacter.lowercased()
guard applyShiftSymbolNormalization else { return lowered }
switch lowered {
case "{": return "["
case "}": return "]"
case "<": return eventKeyCode == 43 ? "," : lowered // kVK_ANSI_Comma
case ">": return eventKeyCode == 47 ? "." : lowered // kVK_ANSI_Period
case "?": return "/"
case ":": return ";"
case "\"": return "'"
case "|": return "\\"
case "~": return "`"
case "+": return "="
case "_": return "-"
case "!": return eventKeyCode == 18 ? "1" : lowered // kVK_ANSI_1
case "@": return eventKeyCode == 19 ? "2" : lowered // kVK_ANSI_2
case "#": return eventKeyCode == 20 ? "3" : lowered // kVK_ANSI_3
case "$": return eventKeyCode == 21 ? "4" : lowered // kVK_ANSI_4
case "%": return eventKeyCode == 23 ? "5" : lowered // kVK_ANSI_5
case "^": return eventKeyCode == 22 ? "6" : lowered // kVK_ANSI_6
case "&": return eventKeyCode == 26 ? "7" : lowered // kVK_ANSI_7
case "*": return eventKeyCode == 28 ? "8" : lowered // kVK_ANSI_8
case "(": return eventKeyCode == 25 ? "9" : lowered // kVK_ANSI_9
case ")": return eventKeyCode == 29 ? "0" : lowered // kVK_ANSI_0
default: return lowered
}
}
private func keyCodeForShortcutKey(_ key: String) -> UInt16? {
// Matches macOS ANSI key codes. This is intentionally limited to keys we
// support in StoredShortcut/ghostty trigger translation.
@ -7470,8 +7680,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
case "-": return 27 // kVK_ANSI_Minus
case "8": return 28 // kVK_ANSI_8
case "0": return 29 // kVK_ANSI_0
case "]": return 30 // kVK_ANSI_RightBracket
case "o": return 31 // kVK_ANSI_O
case "u": return 32 // kVK_ANSI_U
case "[": return 33 // kVK_ANSI_LeftBracket
case "i": return 34 // kVK_ANSI_I
case "p": return 35 // kVK_ANSI_P
case "l": return 37 // kVK_ANSI_L
@ -7943,16 +8155,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
#endif
if let notificationId, let store = notificationStore {
markReadIfFocused(
notificationId: notificationId,
tabId: tabId,
surfaceId: surfaceId,
tabManager: context.tabManager,
notificationStore: store
)
}
#if DEBUG
recordMultiWindowNotificationFocusIfNeeded(
windowId: context.windowId,
@ -8006,15 +8208,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
#endif
if let notificationId, let store = notificationStore {
markReadIfFocused(
notificationId: notificationId,
tabId: tabId,
surfaceId: surfaceId,
tabManager: tabManager,
notificationStore: store
)
}
#if DEBUG
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" {
writeJumpUnreadTestData(["jumpUnreadOpenInFallback": "1", "jumpUnreadOpenResult": "1"])
@ -8074,22 +8267,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
}
private func markReadIfFocused(
notificationId: UUID,
tabId: UUID,
surfaceId: UUID?,
tabManager: TabManager,
notificationStore: TerminalNotificationStore
) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
guard tabManager.selectedTabId == tabId else { return }
if let surfaceId {
guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return }
}
notificationStore.markRead(id: notificationId)
}
}
#if DEBUG
private func recordMultiWindowNotificationOpenFailureIfNeeded(
tabId: UUID,

File diff suppressed because it is too large Load diff

View file

@ -631,7 +631,12 @@ final class FileDropOverlayView: NSView {
}
/// Hit-tests the window to find a WKWebView (browser panel) under the cursor.
private func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? {
func webViewUnderPoint(_ windowPoint: NSPoint) -> WKWebView? {
if let window,
let portalWebView = BrowserWindowPortalRegistry.webViewAtWindowPoint(windowPoint, in: window) {
return portalWebView
}
guard let window, let contentView = window.contentView else { return nil }
isHidden = true
defer { isHidden = false }
@ -5707,7 +5712,7 @@ struct VerticalTabsSidebar: View {
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
@StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor()
@StateObject private var modifierKeyMonitor = SidebarShortcutHintModifierMonitor()
@StateObject private var dragAutoScrollController = SidebarDragAutoScrollController()
@StateObject private var dragFailsafeMonitor = SidebarDragFailsafeMonitor()
@State private var draggedTabId: UUID?
@ -5735,7 +5740,7 @@ struct VerticalTabsSidebar: View {
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
showsCommandShortcutHints: commandKeyMonitor.isCommandPressed,
showsModifierShortcutHints: modifierKeyMonitor.isModifierPressed,
dragAutoScrollController: dragAutoScrollController,
draggedTabId: $draggedTabId,
dropIndicator: $dropIndicator
@ -5791,12 +5796,12 @@ struct VerticalTabsSidebar: View {
.background(SidebarBackdrop().ignoresSafeArea())
.background(
WindowAccessor { window in
commandKeyMonitor.setHostWindow(window)
modifierKeyMonitor.setHostWindow(window)
}
.frame(width: 0, height: 0)
)
.onAppear {
commandKeyMonitor.start()
modifierKeyMonitor.start()
draggedTabId = nil
dropIndicator = nil
SidebarDragLifecycleNotification.postStateDidChange(
@ -5805,7 +5810,7 @@ struct VerticalTabsSidebar: View {
)
}
.onDisappear {
commandKeyMonitor.stop()
modifierKeyMonitor.stop()
dragAutoScrollController.stop()
dragFailsafeMonitor.stop()
draggedTabId = nil
@ -5849,15 +5854,18 @@ struct VerticalTabsSidebar: View {
}
}
enum SidebarCommandHintPolicy {
enum ShortcutHintModifierPolicy {
static let intentionalHoldDelay: TimeInterval = 0.30
static func shouldShowHints(
for modifierFlags: NSEvent.ModifierFlags,
defaults: UserDefaults = .standard
) -> Bool {
ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults) &&
modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command]
let normalized = modifierFlags.intersection(.deviceIndependentFlagsMask)
guard normalized == [.command] else {
return false
}
return ShortcutHintDebugSettings.showHintsOnCommandHoldEnabled(defaults: defaults)
}
static func isCurrentWindow(
@ -5922,6 +5930,11 @@ enum ShortcutHintDebugSettings {
}
return defaults.bool(forKey: showHintsOnCommandHoldKey)
}
static func resetVisibilityDefaults(defaults: UserDefaults = .standard) {
defaults.set(defaultAlwaysShowHints, forKey: alwaysShowHintsKey)
defaults.set(defaultShowHintsOnCommandHold, forKey: showHintsOnCommandHoldKey)
}
}
enum SidebarDragLifecycleNotification {
@ -6139,8 +6152,8 @@ private struct SidebarExternalDropDelegate: DropDelegate {
}
@MainActor
private final class SidebarCommandKeyMonitor: ObservableObject {
@Published private(set) var isCommandPressed = false
private final class SidebarShortcutHintModifierMonitor: ObservableObject {
@Published private(set) var isModifierPressed = false
private weak var hostWindow: NSWindow?
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
@ -6234,7 +6247,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
}
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
SidebarCommandHintPolicy.isCurrentWindow(
ShortcutHintModifierPolicy.isCurrentWindow(
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
eventWindowNumber: eventWindow?.windowNumber,
@ -6243,7 +6256,7 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
}
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: modifierFlags,
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
@ -6258,31 +6271,31 @@ private final class SidebarCommandKeyMonitor: ObservableObject {
}
private func queueHintShow() {
guard !isCommandPressed else { return }
guard !isModifierPressed else { return }
guard pendingShowWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingShowWorkItem = nil
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: NSEvent.modifierFlags,
hostWindowNumber: self.hostWindow?.windowNumber,
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
eventWindowNumber: nil,
keyWindowNumber: NSApp.keyWindow?.windowNumber
) else { return }
self.isCommandPressed = true
self.isModifierPressed = true
}
pendingShowWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem)
}
private func cancelPendingHintShow(resetVisible: Bool) {
pendingShowWorkItem?.cancel()
pendingShowWorkItem = nil
if resetVisible {
isCommandPressed = false
isModifierPressed = false
}
}
@ -6520,7 +6533,7 @@ private struct TabItemView: View {
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
let showsCommandShortcutHints: Bool
let showsModifierShortcutHints: Bool
let dragAutoScrollController: SidebarDragAutoScrollController
@Binding var draggedTabId: UUID?
@Binding var dropIndicator: SidebarDropIndicator?
@ -6623,7 +6636,7 @@ private struct TabItemView: View {
}
private var showCloseButton: Bool {
isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints)
isHovering && tabManager.tabs.count > 1 && !(showsModifierShortcutHints || alwaysShowShortcutHints)
}
private var workspaceShortcutLabel: String? {
@ -6632,7 +6645,7 @@ private struct TabItemView: View {
}
private var showsWorkspaceShortcutHint: Bool {
(showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil
(showsModifierShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil
}
private var workspaceHintSlotWidth: CGFloat {
@ -6748,7 +6761,7 @@ private struct TabItemView: View {
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints)
.animation(.easeInOut(duration: 0.14), value: showsModifierShortcutHints || alwaysShowShortcutHints)
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
}

View file

@ -14,11 +14,21 @@ struct BrowserSearchOverlay: View {
private let padding: CGFloat = 8
private func requestSearchFieldFocus(maxAttempts: Int = 3) {
guard maxAttempts > 0 else { return }
isSearchFieldFocused = true
guard maxAttempts > 1 else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
requestSearchFieldFocus(maxAttempts: maxAttempts - 1)
}
}
var body: some View {
GeometryReader { geo in
HStack(spacing: 4) {
TextField("Search", text: $searchState.needle)
.textFieldStyle(.plain)
.accessibilityIdentifier("BrowserFindSearchTextField")
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
@ -95,13 +105,13 @@ struct BrowserSearchOverlay: View {
#if DEBUG
dlog("browser.findbar.appear panel=\(panelId.uuidString.prefix(5))")
#endif
isSearchFieldFocused = true
requestSearchFieldFocus()
}
.onReceive(NotificationCenter.default.publisher(for: .browserSearchFocus)) { notification in
guard let notifiedPanelId = notification.object as? UUID,
notifiedPanelId == panelId else { return }
DispatchQueue.main.async {
isSearchFieldFocused = true
requestSearchFieldFocus()
}
}
.background(

View file

@ -55,6 +55,7 @@ struct SurfaceSearchOverlay: View {
onNavigateSearch(action)
}
)
.accessibilityIdentifier("TerminalFindSearchTextField")
.frame(width: 180)
.padding(.leading, 8)
.padding(.trailing, 50)
@ -303,6 +304,7 @@ private struct SearchTextFieldRepresentable: NSViewRepresentable {
let field = SearchNativeTextField(frame: .zero)
field.font = .systemFont(ofSize: NSFont.systemFontSize)
field.placeholderString = String(localized: "search.placeholder", defaultValue: "Search")
field.setAccessibilityIdentifier("TerminalFindSearchTextField")
field.delegate = context.coordinator
field.stringValue = text
context.coordinator.parentField = field

View file

@ -1,3 +1,4 @@
import AppKit
import Carbon
class KeyboardLayout {
@ -12,8 +13,12 @@ class KeyboardLayout {
return nil
}
/// Translate a physical keyCode to the unmodified character under the current keyboard layout.
static func character(forKeyCode keyCode: UInt16) -> String? {
/// Translate a physical keyCode to the character AppKit would use for shortcut matching,
/// preserving command-aware layouts such as "Dvorak - QWERTY Command".
static func character(
forKeyCode keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags = []
) -> String? {
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return nil
@ -31,7 +36,7 @@ class KeyboardLayout {
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
0,
translationModifierKeyState(for: modifierFlags),
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
@ -43,4 +48,20 @@ class KeyboardLayout {
guard status == noErr, length > 0 else { return nil }
return String(utf16CodeUnits: chars, count: length).lowercased()
}
private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 {
let normalized = modifierFlags
.intersection(.deviceIndependentFlagsMask)
.intersection([.shift, .command])
var carbonModifiers: Int = 0
if normalized.contains(.shift) {
carbonModifiers |= shiftKey
}
if normalized.contains(.command) {
carbonModifiers |= cmdKey
}
return UInt32((carbonModifiers >> 8) & 0xFF)
}
}

View file

@ -1595,10 +1595,8 @@ final class BrowserPanel: Panel, ObservableObject {
Task { @MainActor [weak self] in
self?.refreshFavicon(from: webView)
self?.applyBrowserThemeModeIfNeeded()
// Clear find-in-page on navigation so stale highlights don't persist.
if self?.searchState != nil {
self?.searchState = nil
}
// Keep find-in-page open through load completion and refresh matches for the new DOM.
self?.restoreFindStateAfterNavigation(replaySearch: true)
}
}
navDelegate.didFailNavigation = { [weak self] _, failedURL in
@ -1609,10 +1607,8 @@ final class BrowserPanel: Panel, ObservableObject {
self.pageTitle = failedURL.isEmpty ? "" : failedURL
self.faviconPNGData = nil
self.lastFaviconURLString = nil
// Clear find-in-page so stale highlights don't persist.
if self.searchState != nil {
self.searchState = nil
}
// Keep find-in-page open and clear stale counters on failed loads.
self.restoreFindStateAfterNavigation(replaySearch: false)
}
}
navDelegate.openInNewTab = { [weak self] url in
@ -2645,6 +2641,18 @@ extension BrowserPanel {
if searchState == nil {
searchState = BrowserSearchState()
}
postBrowserSearchFocusNotification()
// Focus notification can race with portal overlay mount. Re-post on the
// next runloop and shortly after so the find field can claim first responder.
DispatchQueue.main.async { [weak self] in
self?.postBrowserSearchFocusNotification()
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.postBrowserSearchFocusNotification()
}
}
private func postBrowserSearchFocusNotification() {
NotificationCenter.default.post(name: .browserSearchFocus, object: id)
}
@ -2668,6 +2676,16 @@ extension BrowserPanel {
searchState = nil
}
private func restoreFindStateAfterNavigation(replaySearch: Bool) {
guard let state = searchState else { return }
state.total = nil
state.selected = nil
if replaySearch, !state.needle.isEmpty {
executeFindSearch(state.needle)
}
postBrowserSearchFocusNotification()
}
private func executeFindSearch(_ needle: String) {
guard !needle.isEmpty else {
executeFindClear()
@ -2743,6 +2761,9 @@ extension BrowserPanel {
if suppressWebViewFocusForAddressBar {
return true
}
if searchState != nil {
return true
}
if let until = suppressWebViewFocusUntil {
return Date() < until
}

View file

@ -211,6 +211,7 @@ struct BrowserPanelView: View {
let portalPriority: Int
let onRequestPanelFocus: () -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.paneDropZone) private var paneDropZone
@State private var omnibarState = OmnibarState()
@State private var addressBarFocused: Bool = false
@AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue
@ -317,7 +318,10 @@ struct BrowserPanelView: View {
.allowsHitTesting(false)
}
.overlay {
if let searchState = panel.searchState {
// Keep Cmd+F usable when the browser is still in the empty new-tab
// state (no WKWebView mounted yet). WebView-backed cases are hosted
// in AppKit by WebViewRepresentable to avoid layering/clipping issues.
if !panel.shouldRenderWebView, let searchState = panel.searchState {
BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
@ -734,10 +738,12 @@ struct BrowserPanelView: View {
if panel.shouldRenderWebView {
WebViewRepresentable(
panel: panel,
browserSearchState: panel.searchState,
shouldAttachWebView: isVisibleInUI,
shouldFocusWebView: isFocused && !addressBarFocused,
isPanelFocused: isFocused,
portalZPriority: portalPriority
portalZPriority: portalPriority,
paneDropZone: paneDropZone
)
// Keep the host stable for normal pane churn, but force a remount when
// BrowserPanel replaces its underlying WKWebView after process termination.
@ -3032,10 +3038,12 @@ private struct OmnibarSuggestionsView: View {
/// NSViewRepresentable wrapper for WKWebView
struct WebViewRepresentable: NSViewRepresentable {
let panel: BrowserPanel
let browserSearchState: BrowserSearchState?
let shouldAttachWebView: Bool
let shouldFocusWebView: Bool
let isPanelFocused: Bool
let portalZPriority: Int
let paneDropZone: DropZone?
final class Coordinator {
weak var panel: BrowserPanel?
@ -3044,6 +3052,7 @@ struct WebViewRepresentable: NSViewRepresentable {
var desiredPortalVisibleInUI: Bool = true
var desiredPortalZPriority: Int = 0
var lastPortalHostId: ObjectIdentifier?
var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?
}
private final class HostContainerView: NSView {
@ -3196,6 +3205,67 @@ struct WebViewRepresentable: NSViewRepresentable {
host.onGeometryChanged = nil
}
private static func removeSearchOverlay(from coordinator: Coordinator) {
coordinator.searchOverlayHostingView?.removeFromSuperview()
coordinator.searchOverlayHostingView = nil
}
private static func updateSearchOverlay(
panel: BrowserPanel,
coordinator: Coordinator,
containerView: NSView?
) {
// Layering contract: keep browser Cmd+F UI in the portal-hosted AppKit layer.
// SwiftUI panel overlays can be covered by portal-hosted WKWebView content.
guard let searchState = panel.searchState,
let containerView else {
removeSearchOverlay(from: coordinator)
return
}
let rootView = BrowserSearchOverlay(
panelId: panel.id,
searchState: searchState,
onNext: { [weak panel] in
panel?.findNext()
},
onPrevious: { [weak panel] in
panel?.findPrevious()
},
onClose: { [weak panel] in
panel?.hideFind()
}
)
if let overlay = coordinator.searchOverlayHostingView {
overlay.rootView = rootView
if overlay.superview !== containerView {
overlay.removeFromSuperview()
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
])
} else if containerView.subviews.last !== overlay {
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
}
return
}
let overlay = NSHostingView(rootView: rootView)
overlay.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(overlay, positioned: .above, relativeTo: nil)
NSLayoutConstraint.activate([
overlay.topAnchor.constraint(equalTo: containerView.topAnchor),
overlay.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
overlay.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
overlay.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
])
coordinator.searchOverlayHostingView = overlay
}
private func updateUsingWindowPortal(_ nsView: NSView, context: Context, webView: WKWebView) {
guard let host = nsView as? HostContainerView else { return }
@ -3206,6 +3276,7 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.desiredPortalZPriority = portalZPriority
coordinator.attachGeneration += 1
let generation = coordinator.attachGeneration
let paneDropContext = shouldAttachWebView ? currentPaneDropContext() : nil
host.onDidMoveToWindow = { [weak host, weak webView, weak coordinator] in
guard let host, let webView, let coordinator else { return }
@ -3217,7 +3288,15 @@ struct WebViewRepresentable: NSViewRepresentable {
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: paneDropContext)
coordinator.lastPortalHostId = ObjectIdentifier(host)
if let panel = coordinator.panel {
Self.updateSearchOverlay(
panel: panel,
coordinator: coordinator,
containerView: webView.superview
)
}
}
host.onGeometryChanged = { [weak host, weak coordinator] in
guard let host, let coordinator else { return }
@ -3249,6 +3328,11 @@ struct WebViewRepresentable: NSViewRepresentable {
coordinator.lastPortalHostId = hostId
}
BrowserWindowPortalRegistry.synchronizeForAnchor(host)
Self.updateSearchOverlay(
panel: panel,
coordinator: coordinator,
containerView: webView.superview
)
} else {
// Bind is deferred until host moves into a window. Keep the current
// portal entry's desired state in sync so stale callbacks cannot keep
@ -3258,8 +3342,18 @@ struct WebViewRepresentable: NSViewRepresentable {
visibleInUI: coordinator.desiredPortalVisibleInUI,
zPriority: coordinator.desiredPortalZPriority
)
Self.removeSearchOverlay(from: coordinator)
}
BrowserWindowPortalRegistry.updateDropZoneOverlay(
for: webView,
zone: shouldAttachWebView ? paneDropZone : nil
)
BrowserWindowPortalRegistry.updatePaneDropContext(
for: webView,
context: paneDropContext
)
panel.restoreDeveloperToolsAfterAttachIfNeeded()
#if DEBUG
@ -3277,6 +3371,7 @@ struct WebViewRepresentable: NSViewRepresentable {
let webView = panel.webView
let coordinator = context.coordinator
if let previousWebView = coordinator.webView, previousWebView !== webView {
Self.removeSearchOverlay(from: coordinator)
BrowserWindowPortalRegistry.detach(webView: previousWebView)
coordinator.lastPortalHostId = nil
}
@ -3348,6 +3443,7 @@ struct WebViewRepresentable: NSViewRepresentable {
static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
coordinator.attachGeneration += 1
clearPortalCallbacks(for: nsView)
removeSearchOverlay(from: coordinator)
guard let webView = coordinator.webView else { return }
let panel = coordinator.panel
@ -3372,7 +3468,24 @@ struct WebViewRepresentable: NSViewRepresentable {
window.makeFirstResponder(nil)
}
}
BrowserWindowPortalRegistry.detach(webView: webView)
// SwiftUI can transiently dismantle/rebuild the browser host view during split
// rearrangement. Do not detach the portal-hosted WKWebView here; explicit detach
// still happens on real web view replacement and panel teardown.
BrowserWindowPortalRegistry.updateDropZoneOverlay(for: webView, zone: nil)
BrowserWindowPortalRegistry.updatePaneDropContext(for: webView, context: nil)
coordinator.lastPortalHostId = nil
}
private func currentPaneDropContext() -> BrowserPaneDropContext? {
guard let workspace = AppDelegate.shared?.tabManager?.tabs.first(where: { $0.id == panel.workspaceId }),
let paneId = workspace.paneId(forPanelId: panel.id) else {
return nil
}
return BrowserPaneDropContext(
workspaceId: panel.workspaceId,
panelId: panel.id,
paneId: paneId
)
}
}

View file

@ -1113,6 +1113,11 @@ final class CmuxWebView: WKWebView {
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
]
static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool {
DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
}
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
if !filtered.isEmpty {
@ -1120,6 +1125,21 @@ final class CmuxWebView: WKWebView {
}
}
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
return super.draggingEntered(sender)
}
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
return super.draggingUpdated(sender)
}
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false }
return super.performDragOperation(sender)
}
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
lastContextMenuPoint = convert(event.locationInWindow, from: nil)

View file

@ -600,7 +600,7 @@ class TabManager: ObservableObject {
self.focusSelectedTabPanel(previousTabId: previousTabId)
self.updateWindowTitleForSelectedTab()
if let selectedTabId = self.selectedTabId {
self.markFocusedPanelReadIfActive(tabId: selectedTabId)
self.flashFocusedPanelIfUnreadAndActive(tabId: selectedTabId)
}
#if DEBUG
let dtMs = self.debugWorkspaceSwitchStartTime > 0
@ -672,7 +672,7 @@ class TabManager: ObservableObject {
guard let self else { return }
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: surfaceId)
flashPanelIfUnreadAndActive(tabId: tabId, panelId: surfaceId)
}
})
@ -1618,16 +1618,16 @@ class TabManager: ObservableObject {
selectedTabId != pendingTabId
}
private func markFocusedPanelReadIfActive(tabId: UUID) {
private func flashFocusedPanelIfUnreadAndActive(tabId: UUID) {
let shouldSuppressFlash = suppressFocusFlash
suppressFocusFlash = false
guard !shouldSuppressFlash else { return }
guard AppFocusState.isAppActive() else { return }
guard let panelId = focusedPanelId(for: tabId) else { return }
markPanelReadOnFocusIfActive(tabId: tabId, panelId: panelId)
flashPanelIfUnreadAndActive(tabId: tabId, panelId: panelId)
}
private func markPanelReadOnFocusIfActive(tabId: UUID, panelId: UUID) {
private func flashPanelIfUnreadAndActive(tabId: UUID, panelId: UUID) {
guard selectedTabId == tabId else { return }
guard !suppressFocusFlash else { return }
guard AppFocusState.isAppActive() else { return }
@ -1636,7 +1636,6 @@ class TabManager: ObservableObject {
if let tab = tabs.first(where: { $0.id == tabId }) {
tab.triggerNotificationFocusFlash(panelId: panelId, requiresSplit: false, shouldFocus: false)
}
notificationStore.markRead(forTabId: tabId, surfaceId: panelId)
}
private func enqueuePanelTitleUpdate(tabId: UUID, panelId: UUID, title: String) {
@ -1762,7 +1761,6 @@ class TabManager: ObservableObject {
guard let notificationStore = AppDelegate.shared?.notificationStore else { return }
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: targetPanelId) else { return }
tab.triggerNotificationFocusFlash(panelId: targetPanelId, requiresSplit: false, shouldFocus: true)
notificationStore.markRead(forTabId: tabId, surfaceId: targetPanelId)
}
}

View file

@ -13,6 +13,9 @@ class TerminalController {
let acceptLoopAlive: Bool
let socketPathMatches: Bool
let socketPathExists: Bool
let socketProbePerformed: Bool
let socketConnectable: Bool?
let socketConnectErrno: Int32?
var failureSignals: [String] {
var signals: [String] = []
@ -20,6 +23,9 @@ class TerminalController {
if !acceptLoopAlive { signals.append("accept_loop_dead") }
if !socketPathMatches { signals.append("socket_path_mismatch") }
if !socketPathExists { signals.append("socket_missing") }
if socketProbePerformed && isRunning && acceptLoopAlive && socketPathMatches && socketPathExists && socketConnectable == false {
signals.append("socket_unreachable")
}
return signals
}
@ -51,6 +57,14 @@ class TerminalController {
private nonisolated static let acceptFailureMaxBackoffMs = 5_000
private nonisolated static let acceptFailureMinimumRearmDelayMs = 100
private nonisolated static let acceptFailureRearmThreshold = 50
private nonisolated static let socketProbePollTimeoutMs: Int32 = 100
private nonisolated static let socketProbePollAttempts = 3
private nonisolated static let socketProbePollRetryBackoffUs: useconds_t = 50_000
private nonisolated static let unixSocketPathMaxLength: Int = {
var addr = sockaddr_un()
// Reserve one byte for the null terminator.
return MemoryLayout.size(ofValue: addr.sun_path) - 1
}()
private struct ListenerStateSnapshot {
let socketPath: String
@ -508,6 +522,99 @@ class TerminalController {
return !isRunning && activeGeneration == 0
}
private nonisolated static func unixSocketAddress(path: String) -> sockaddr_un? {
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLength = unixSocketPathMaxLength + 1
var didFit = false
path.withCString { source in
let sourceLength = strlen(source)
guard sourceLength < maxLength else { return }
_ = withUnsafeMutableBytes(of: &addr.sun_path) { buffer in
buffer.initializeMemory(as: UInt8.self, repeating: 0)
}
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let destination = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strncpy(destination, source, maxLength - 1)
}
didFit = true
}
return didFit ? addr : nil
}
private nonisolated static func bindUnixSocket(_ socket: Int32, path: String) -> Int32? {
guard var addr = unixSocketAddress(path: path) else { return nil }
return withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
bind(socket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
}
private nonisolated static func probeSocketConnectability(path: String) -> (isConnectable: Bool?, errnoCode: Int32?) {
let probeSocket = socket(AF_UNIX, SOCK_STREAM, 0)
guard probeSocket >= 0 else {
return (false, errno)
}
defer { close(probeSocket) }
let existingFlags = fcntl(probeSocket, F_GETFL, 0)
if existingFlags >= 0 {
_ = fcntl(probeSocket, F_SETFL, existingFlags | O_NONBLOCK)
}
guard var addr = unixSocketAddress(path: path) else {
return (false, ENAMETOOLONG)
}
let connectResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
connect(probeSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
if connectResult == 0 {
return (true, nil)
}
let connectErrno = errno
if connectErrno == EINPROGRESS {
var pollDescriptor = pollfd(fd: probeSocket, events: Int16(POLLOUT), revents: 0)
for attempt in 0..<Self.socketProbePollAttempts {
pollDescriptor.revents = 0
let pollResult = poll(&pollDescriptor, 1, Self.socketProbePollTimeoutMs)
if pollResult > 0 {
var socketError: Int32 = 0
var socketErrorLength = socklen_t(MemoryLayout<Int32>.size)
let status = getsockopt(
probeSocket,
SOL_SOCKET,
SO_ERROR,
&socketError,
&socketErrorLength
)
if status == 0 && socketError == 0 {
return (true, nil)
}
if status == 0 {
return (false, socketError)
}
return (false, errno)
}
let pollErrno = errno
if pollResult == 0 || pollErrno == EINTR {
if attempt + 1 < Self.socketProbePollAttempts {
usleep(Self.socketProbePollRetryBackoffUs)
continue
}
return (false, pollResult == 0 ? ETIMEDOUT : pollErrno)
}
return (false, pollErrno)
}
}
return (false, connectErrno)
}
func start(tabManager: TabManager, socketPath: String, accessMode: SocketControlMode) {
self.tabManager = tabManager
self.accessMode = accessMode
@ -556,19 +663,18 @@ class TerminalController {
}
// Bind to path
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
socketPath.withCString { ptr in
withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in
let pathBuf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self)
strcpy(pathBuf, ptr)
}
}
let bindResult = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in
bind(newServerSocket, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size))
}
guard let bindResult = Self.bindUnixSocket(newServerSocket, path: socketPath) else {
close(newServerSocket)
reportSocketListenerFailure(
message: "socket.listener.start.failed",
stage: "bind_path_too_long",
errnoCode: ENAMETOOLONG,
extra: [
"pathLength": socketPath.utf8.count,
"maxPathLength": Self.unixSocketPathMaxLength
]
)
return
}
guard bindResult >= 0 else {
@ -653,12 +759,19 @@ class TerminalController {
var st = stat()
let exists = lstat(expectedSocketPath, &st) == 0 && (st.st_mode & S_IFMT) == S_IFSOCK
let shouldProbeConnection = snapshot.isRunning && snapshot.acceptLoopAlive && pathMatches && exists
let connectability = shouldProbeConnection
? Self.probeSocketConnectability(path: expectedSocketPath)
: (isConnectable: nil, errnoCode: nil)
return SocketListenerHealth(
isRunning: snapshot.isRunning,
acceptLoopAlive: snapshot.acceptLoopAlive,
socketPathMatches: pathMatches,
socketPathExists: exists
socketPathExists: exists,
socketProbePerformed: shouldProbeConnection,
socketConnectable: connectability.isConnectable,
socketConnectErrno: connectability.errnoCode
)
}
@ -5285,41 +5398,70 @@ class TerminalController {
_ webView: WKWebView,
script: String,
timeout: TimeInterval = 5.0,
preferAsync: Bool = false
preferAsync: Bool = false,
contentWorld: WKContentWorld
) -> V2JavaScriptResult {
let timeoutSeconds = max(0.01, timeout)
let resultLock = NSLock()
let completionSignal = DispatchSemaphore(value: 0)
var done = false
var resultValue: Any?
var resultError: String?
if preferAsync, #available(macOS 11.0, *) {
webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: .page) { result in
switch result {
case .success(let value):
resultValue = value
case .failure(let error):
resultError = error.localizedDescription
}
let finish: (_ value: Any?, _ error: String?) -> Void = { value, error in
resultLock.lock()
if !done {
done = true
resultValue = value
resultError = error
completionSignal.signal()
}
resultLock.unlock()
}
let evaluator = {
if preferAsync, #available(macOS 11.0, *) {
webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in
switch result {
case .success(let value):
finish(value, nil)
case .failure(let error):
finish(nil, error.localizedDescription)
}
}
} else {
webView.evaluateJavaScript(script) { value, error in
if let error {
finish(nil, error.localizedDescription)
} else {
finish(value, nil)
}
}
}
}
if Thread.isMainThread {
evaluator()
let deadline = Date().addingTimeInterval(timeoutSeconds)
while true {
resultLock.lock()
let isDone = done
resultLock.unlock()
if isDone {
break
}
if Date() >= deadline {
return .failure("Timed out waiting for JavaScript result")
}
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
} else {
webView.evaluateJavaScript(script) { value, error in
if let error {
resultError = error.localizedDescription
} else {
resultValue = value
}
done = true
DispatchQueue.main.async(execute: evaluator)
if completionSignal.wait(timeout: .now() + timeoutSeconds) == .timedOut {
return .failure("Timed out waiting for JavaScript result")
}
}
let deadline = Date().addingTimeInterval(timeout)
while !done && Date() < deadline {
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01))
}
if !done {
return .failure("Timed out waiting for JavaScript result")
}
if let resultError {
return .failure(resultError)
}
@ -5369,7 +5511,8 @@ class TerminalController {
_ webView: WKWebView,
surfaceId: UUID,
script: String,
timeout: TimeInterval = 5.0
timeout: TimeInterval = 5.0,
useEval: Bool = true
) -> V2JavaScriptResult {
let scriptLiteral = v2JSONLiteral(script)
let framePrelude: String
@ -5388,6 +5531,13 @@ class TerminalController {
framePrelude = "const __cmuxDoc = document;"
}
let executionBlock: String
if useEval {
executionBlock = "const __r = eval(\(scriptLiteral));"
} else {
executionBlock = "const __r = \(script);"
}
let asyncFunctionBody = """
\(framePrelude)
@ -5400,7 +5550,7 @@ class TerminalController {
const __cmuxEvalInFrame = async function() {
const document = __cmuxDoc;
const __r = eval(\(scriptLiteral));
\(executionBlock)
const __value = await __cmuxMaybeAwait(__r);
return {
__cmux_t: (typeof __value === 'undefined') ? 'undefined' : 'value',
@ -5411,16 +5561,40 @@ class TerminalController {
return await __cmuxEvalInFrame();
"""
let rawResult: V2JavaScriptResult
var rawResult: V2JavaScriptResult
if #available(macOS 11.0, *) {
rawResult = v2RunJavaScript(webView, script: asyncFunctionBody, timeout: timeout, preferAsync: true)
rawResult = v2RunJavaScript(
webView,
script: asyncFunctionBody,
timeout: timeout,
preferAsync: true,
contentWorld: .page
)
} else {
let evaluateFallback = """
(async () => {
\(asyncFunctionBody)
})()
"""
rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout)
rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout, contentWorld: .page)
}
if !useEval, case .failure(let pageMessage) = rawResult, #available(macOS 11.0, *) {
let isolatedResult = v2RunJavaScript(
webView,
script: asyncFunctionBody,
timeout: timeout,
preferAsync: true,
contentWorld: .defaultClient
)
switch isolatedResult {
case .success:
rawResult = isolatedResult
case .failure(let isolatedMessage):
if isolatedMessage != pageMessage {
rawResult = .failure("\(pageMessage) (isolated-world retry: \(isolatedMessage))")
}
}
}
switch rawResult {
@ -5521,38 +5695,41 @@ class TerminalController {
}
}
private func v2BrowserWaitForCondition(
_ conditionScript: String,
webView: WKWebView,
surfaceId: UUID? = nil,
timeout: TimeInterval = 5.0,
pollInterval: TimeInterval = 0.05
) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let wrapped = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()"
let jsResult: V2JavaScriptResult
if let surfaceId {
jsResult = v2RunBrowserJavaScript(webView, surfaceId: surfaceId, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
} else {
jsResult = v2RunJavaScript(webView, script: wrapped, timeout: max(0.5, pollInterval + 0.25))
}
if case let .success(value) = jsResult,
let ok = value as? Bool,
ok {
return true
}
_ = RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(pollInterval))
}
return false
}
private func v2PNGData(from image: NSImage) -> Data? {
guard let tiff = image.tiffRepresentation,
let rep = NSBitmapImageRep(data: tiff) else { return nil }
return rep.representation(using: .png, properties: [:])
}
private func bestEffortPruneTemporaryFiles(
in directoryURL: URL,
keepingMostRecent maxCount: Int = 50,
maxAge: TimeInterval = 24 * 60 * 60
) {
guard let entries = try? FileManager.default.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey],
options: [.skipsHiddenFiles]
) else {
return
}
let now = Date()
let datedEntries = entries.compactMap { url -> (url: URL, date: Date)? in
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .contentModificationDateKey, .creationDateKey]),
values.isRegularFile == true else {
return nil
}
return (url, values.contentModificationDate ?? values.creationDate ?? .distantPast)
}.sorted { $0.date > $1.date }
for (index, entry) in datedEntries.enumerated() {
if index >= maxCount || now.timeIntervalSince(entry.date) > maxAge {
try? FileManager.default.removeItem(at: entry.url)
}
}
}
// MARK: - Markdown
private func v2MarkdownOpen(params: [String: Any]) -> V2CallResult {
@ -5973,7 +6150,7 @@ class TerminalController {
let retryAttempts = max(1, v2Int(params, "retry_attempts") ?? 3)
for attempt in 1...retryAttempts {
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script) {
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, useEval: false) {
case .failure(let message):
return .err(code: "js_error", message: message, data: ["action": actionName, "selector": selector])
case .success(let value):
@ -6231,7 +6408,7 @@ class TerminalController {
})()
"""
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0) {
switch v2RunBrowserJavaScript(browserPanel.webView, surfaceId: surfaceId, script: script, timeout: 10.0, useEval: false) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
@ -6328,42 +6505,120 @@ class TerminalController {
private func v2BrowserWait(params: [String: Any]) -> V2CallResult {
let timeoutMs = max(1, v2Int(params, "timeout_ms") ?? 5_000)
let timeout = Double(timeoutMs) / 1000.0
let selectorRaw = v2BrowserSelector(params)
return v2BrowserWithPanel(params: params) { _, ws, surfaceId, browserPanel in
let conditionScript: String = {
if let selector = v2BrowserSelector(params) {
let literal = v2JSONLiteral(selector)
return "document.querySelector(\(literal)) !== null"
let conditionScriptBase: String = {
if let urlContains = v2String(params, "url_contains") {
let literal = v2JSONLiteral(urlContains)
return "String(location.href || '').includes(\(literal))"
}
if let textContains = v2String(params, "text_contains") {
let literal = v2JSONLiteral(textContains)
return "(document.body && String(document.body.innerText || '').includes(\(literal)))"
}
if let loadState = v2String(params, "load_state") {
let normalizedLoadState = loadState.lowercased()
if normalizedLoadState == "interactive" {
return """
(() => {
const __state = String(document.readyState || '').toLowerCase();
return __state === 'interactive' || __state === 'complete';
})()
"""
}
if let urlContains = v2String(params, "url_contains") {
let literal = v2JSONLiteral(urlContains)
return "String(location.href || '').includes(\(literal))"
}
if let textContains = v2String(params, "text_contains") {
let literal = v2JSONLiteral(textContains)
return "(document.body && String(document.body.innerText || '').includes(\(literal)))"
}
if let loadState = v2String(params, "load_state") {
let literal = v2JSONLiteral(loadState.lowercased())
return "String(document.readyState || '').toLowerCase() === \(literal)"
}
if let fn = v2String(params, "function") {
return "(() => { return !!(\(fn)); })()"
}
return "document.readyState === 'complete'"
}()
let literal = v2JSONLiteral(normalizedLoadState)
return "String(document.readyState || '').toLowerCase() === \(literal)"
}
if let fn = v2String(params, "function") {
return "(() => { return !!(\(fn)); })()"
}
return "document.readyState === 'complete'"
}()
let ok = v2BrowserWaitForCondition(conditionScript, webView: browserPanel.webView, surfaceId: surfaceId, timeout: timeout)
if !ok {
var setupResult: V2CallResult?
var workspaceId: UUID?
var surfaceIdOut: UUID?
var webView: WKWebView?
v2MainSync {
guard let tabManager = self.v2ResolveTabManager(params: params) else {
setupResult = .err(code: "unavailable", message: "TabManager not available", data: nil)
return
}
guard let ws = self.v2ResolveWorkspace(params: params, tabManager: tabManager) else {
setupResult = .err(code: "not_found", message: "Workspace not found", data: nil)
return
}
let surfaceId = self.v2UUID(params, "surface_id") ?? ws.focusedPanelId
guard let surfaceId else {
setupResult = .err(code: "not_found", message: "No focused browser surface", data: nil)
return
}
guard let browserPanel = ws.browserPanel(for: surfaceId) else {
setupResult = .err(code: "invalid_params", message: "Surface is not a browser", data: ["surface_id": surfaceId.uuidString])
return
}
workspaceId = ws.id
surfaceIdOut = surfaceId
webView = browserPanel.webView
}
if let setupResult {
return setupResult
}
guard let workspaceId, let surfaceIdOut, let webView else {
return .err(code: "internal_error", message: "Failed to resolve browser surface", data: nil)
}
let conditionScript: String
if let selectorRaw {
guard let selector = v2BrowserResolveSelector(selectorRaw, surfaceId: surfaceIdOut) else {
return .err(code: "not_found", message: "Element reference not found", data: ["selector": selectorRaw])
}
let literal = v2JSONLiteral(selector)
conditionScript = "document.querySelector(\(literal)) !== null"
} else {
conditionScript = conditionScriptBase
}
let deadline = Date().addingTimeInterval(timeout)
let pollInterval = 0.05
let wrappedScript = "(() => { try { return !!(\(conditionScript)); } catch (_) { return false; } })()"
while true {
switch v2RunBrowserJavaScript(
webView,
surfaceId: surfaceIdOut,
script: wrappedScript,
timeout: max(0.5, pollInterval + 0.25),
useEval: false
) {
case .success(let value):
if let b = value as? Bool, b {
return .ok([
"workspace_id": workspaceId.uuidString,
"workspace_ref": self.v2Ref(kind: .workspace, uuid: workspaceId),
"surface_id": surfaceIdOut.uuidString,
"surface_ref": self.v2Ref(kind: .surface, uuid: surfaceIdOut),
"waited": true
])
}
case .failure(let message):
return .err(
code: "js_error",
message: message,
data: [
"condition": conditionScript,
"timeout_ms": timeoutMs
]
)
}
if Date() >= deadline {
return .err(code: "timeout", message: "Condition not met before timeout", data: ["timeout_ms": timeoutMs])
}
return .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),
"waited": true
])
Thread.sleep(forTimeInterval: pollInterval)
}
}
@ -6708,13 +6963,31 @@ class TerminalController {
return .err(code: "internal_error", message: "Failed to capture snapshot", data: nil)
}
return .ok([
var result: [String: Any] = [
"workspace_id": ws.id.uuidString,
"workspace_ref": v2Ref(kind: .workspace, uuid: ws.id),
"surface_id": surfaceId.uuidString,
"surface_ref": v2Ref(kind: .surface, uuid: surfaceId),
"png_base64": imageData.base64EncodedString()
])
]
// Best effort: keep screenshot data available even when temp-file writes fail.
let screenshotsDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-browser-screenshots", isDirectory: true)
if (try? FileManager.default.createDirectory(at: screenshotsDirectory, withIntermediateDirectories: true)) != nil {
bestEffortPruneTemporaryFiles(in: screenshotsDirectory)
let timestampMs = Int(Date().timeIntervalSince1970 * 1000)
let shortSurfaceId = String(surfaceId.uuidString.prefix(8))
let shortRandomId = String(UUID().uuidString.prefix(8))
let filename = "surface-\(shortSurfaceId)-\(timestampMs)-\(shortRandomId).png"
let imageURL = screenshotsDirectory.appendingPathComponent(filename, isDirectory: false)
if (try? imageData.write(to: imageURL, options: .atomic)) != nil {
result["path"] = imageURL.path
result["url"] = imageURL.absoluteString
}
}
return .ok(result)
}
}
@ -7544,7 +7817,8 @@ class TerminalController {
_ = v2RunJavaScript(
browserPanel.webView,
script: BrowserPanel.telemetryHookBootstrapScriptSource,
timeout: 5.0
timeout: 5.0,
contentWorld: .page
)
}
@ -7552,7 +7826,8 @@ class TerminalController {
_ = v2RunJavaScript(
browserPanel.webView,
script: BrowserPanel.dialogTelemetryHookBootstrapScriptSource,
timeout: 5.0
timeout: 5.0,
contentWorld: .page
)
}
@ -7584,7 +7859,7 @@ class TerminalController {
})()
"""
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
@ -8179,7 +8454,7 @@ class TerminalController {
return { ok: true, items };
})()
"""
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):
@ -8217,7 +8492,7 @@ class TerminalController {
return { ok: true, items };
})()
"""
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0) {
switch v2RunJavaScript(browserPanel.webView, script: script, timeout: 5.0, contentWorld: .page) {
case .failure(let message):
return .err(code: "js_error", message: message, data: nil)
case .success(let value):

View file

@ -833,16 +833,9 @@ final class TerminalNotificationStore: ObservableObject {
let isFocusedSurface = surfaceId == nil || focusedSurfaceId == surfaceId
let isFocusedPanel = isActiveTab && isFocusedSurface
let isAppFocused = AppFocusState.isAppFocused()
if isAppFocused && isFocusedPanel {
if !idsToClear.isEmpty {
notifications = updated
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
}
return
}
let suppressNativeDelivery = isAppFocused && isFocusedPanel
if WorkspaceAutoReorderSettings.isEnabled() {
if WorkspaceAutoReorderSettings.isEnabled() && !suppressNativeDelivery {
AppDelegate.shared?.tabManager?.moveTabToTop(tabId)
}
@ -862,7 +855,11 @@ final class TerminalNotificationStore: ObservableObject {
center.removeDeliveredNotificationsOffMain(withIdentifiers: idsToClear)
center.removePendingNotificationRequestsOffMain(withIdentifiers: idsToClear)
}
scheduleUserNotification(notification)
if suppressNativeDelivery {
Self.runNotificationCustomCommand(notification)
} else {
scheduleUserNotification(notification)
}
}
func markRead(id: UUID) {
@ -993,10 +990,7 @@ final class TerminalNotificationStore: ObservableObject {
guard let self, authorized else { return }
let content = UNMutableNotificationContent()
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
?? "cmux"
content.title = notification.title.isEmpty ? appName : notification.title
content.title = Self.notificationDisplayTitle(notification)
content.subtitle = notification.subtitle
content.body = notification.body
content.sound = NotificationSoundSettings.sound()
@ -1019,16 +1013,27 @@ final class TerminalNotificationStore: ObservableObject {
if let error {
NSLog("Failed to schedule notification: \(error)")
} else {
NotificationSoundSettings.runCustomCommand(
title: content.title,
subtitle: content.subtitle,
body: content.body
)
Self.runNotificationCustomCommand(notification)
}
}
}
}
nonisolated private static func notificationDisplayTitle(_ notification: TerminalNotification) -> String {
let appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String
?? "cmux"
return notification.title.isEmpty ? appName : notification.title
}
nonisolated private static func runNotificationCustomCommand(_ notification: TerminalNotification) {
NotificationSoundSettings.runCustomCommand(
title: notificationDisplayTitle(notification),
subtitle: notification.subtitle,
body: notification.body
)
}
private func ensureAuthorization(
origin: AuthorizationRequestOrigin,
_ completion: @escaping (Bool) -> Void

View file

@ -726,6 +726,7 @@ final class WindowTerminalPortal: NSObject {
guard let window else { return false }
guard let (container, reference) = installedTargetIfStillValid(for: window) ?? installationTarget(for: window)
else { return false }
let browserHost = preferredBrowserHost(in: container)
if hostView.superview !== container ||
installedContainerView !== container ||
@ -734,7 +735,11 @@ final class WindowTerminalPortal: NSObject {
installConstraints.removeAll()
hostView.removeFromSuperview()
container.addSubview(hostView, positioned: .above, relativeTo: reference)
if let browserHost {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
} else {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
installConstraints = [
hostView.leadingAnchor.constraint(equalTo: reference.leadingAnchor),
@ -745,6 +750,10 @@ final class WindowTerminalPortal: NSObject {
NSLayoutConstraint.activate(installConstraints)
installedContainerView = container
installedReferenceView = reference
} else if let browserHost {
if !Self.isView(browserHost, above: hostView, in: container) {
container.addSubview(hostView, positioned: .below, relativeTo: browserHost)
}
} else if !Self.isView(hostView, above: reference, in: container) {
container.addSubview(hostView, positioned: .above, relativeTo: reference)
}
@ -837,6 +846,10 @@ final class WindowTerminalPortal: NSObject {
return viewIndex > referenceIndex
}
private func preferredBrowserHost(in container: NSView) -> WindowBrowserHostView? {
container.subviews.last(where: { $0 is WindowBrowserHostView }) as? WindowBrowserHostView
}
#if DEBUG
private func nearestBonsplitContainer(from anchorView: NSView) -> NSView? {
var current: NSView? = anchorView

View file

@ -237,7 +237,7 @@ struct TitlebarControlsView: View {
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@State private var shortcutRefreshTick = 0
@StateObject private var commandKeyMonitor = TitlebarCommandKeyMonitor()
@StateObject private var modifierKeyMonitor = TitlebarShortcutHintModifierMonitor()
private let titlebarHintRightSafetyShift: CGFloat = 10
private let titlebarHintBaseXShift: CGFloat = -10
private let titlebarHintBaseYShift: CGFloat = 1
@ -269,7 +269,7 @@ struct TitlebarControlsView: View {
}
private var shouldShowTitlebarShortcutHints: Bool {
alwaysShowShortcutHints || commandKeyMonitor.isCommandPressed
alwaysShowShortcutHints || modifierKeyMonitor.isModifierPressed
}
var body: some View {
@ -283,7 +283,7 @@ struct TitlebarControlsView: View {
.padding(.trailing, titlebarHintTrailingInset)
.background(
WindowAccessor { window in
commandKeyMonitor.setHostWindow(window)
modifierKeyMonitor.setHostWindow(window)
}
.frame(width: 0, height: 0)
)
@ -291,10 +291,10 @@ struct TitlebarControlsView: View {
shortcutRefreshTick &+= 1
}
.onAppear {
commandKeyMonitor.start()
modifierKeyMonitor.start()
}
.onDisappear {
commandKeyMonitor.stop()
modifierKeyMonitor.stop()
}
}
@ -503,8 +503,8 @@ struct TitlebarControlsView: View {
}
@MainActor
private final class TitlebarCommandKeyMonitor: ObservableObject {
@Published private(set) var isCommandPressed = false
private final class TitlebarShortcutHintModifierMonitor: ObservableObject {
@Published private(set) var isModifierPressed = false
private weak var hostWindow: NSWindow?
private var hostWindowDidBecomeKeyObserver: NSObjectProtocol?
@ -598,7 +598,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func isCurrentWindow(eventWindow: NSWindow?) -> Bool {
SidebarCommandHintPolicy.isCurrentWindow(
ShortcutHintModifierPolicy.isCurrentWindow(
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
eventWindowNumber: eventWindow?.windowNumber,
@ -607,7 +607,7 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func update(from modifierFlags: NSEvent.ModifierFlags, eventWindow: NSWindow?) {
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: modifierFlags,
hostWindowNumber: hostWindow?.windowNumber,
hostWindowIsKey: hostWindow?.isKeyWindow ?? false,
@ -622,31 +622,31 @@ private final class TitlebarCommandKeyMonitor: ObservableObject {
}
private func queueHintShow() {
guard !isCommandPressed else { return }
guard !isModifierPressed else { return }
guard pendingShowWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingShowWorkItem = nil
guard SidebarCommandHintPolicy.shouldShowHints(
guard ShortcutHintModifierPolicy.shouldShowHints(
for: NSEvent.modifierFlags,
hostWindowNumber: self.hostWindow?.windowNumber,
hostWindowIsKey: self.hostWindow?.isKeyWindow ?? false,
eventWindowNumber: nil,
keyWindowNumber: NSApp.keyWindow?.windowNumber
) else { return }
self.isCommandPressed = true
self.isModifierPressed = true
}
pendingShowWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
DispatchQueue.main.asyncAfter(deadline: .now() + ShortcutHintModifierPolicy.intentionalHoldDelay, execute: workItem)
}
private func cancelPendingHintShow(resetVisible: Bool) {
pendingShowWorkItem?.cancel()
pendingShowWorkItem = nil
if resetVisible {
isCommandPressed = false
isModifierPressed = false
}
}

View file

@ -1968,11 +1968,21 @@ final class Workspace: Identifiable, ObservableObject {
guard let paneId = sourcePaneId else { return nil }
let inheritedConfig = inheritedTerminalConfig(preferredPanelId: panelId, inPane: paneId)
// Inherit working directory: prefer the source panel's reported cwd,
// fall back to the workspace's current directory.
let splitWorkingDirectory: String? = panelDirectories[panelId]
?? (currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil : currentDirectory)
#if DEBUG
dlog("split.cwd panelId=\(panelId.uuidString.prefix(5)) panelDir=\(panelDirectories[panelId] ?? "nil") currentDir=\(currentDirectory) resolved=\(splitWorkingDirectory ?? "nil")")
#endif
// Create the new terminal panel.
let newPanel = TerminalPanel(
workspaceId: id,
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: inheritedConfig,
workingDirectory: splitWorkingDirectory,
portOrdinal: portOrdinal
)
panels[newPanel.id] = newPanel
@ -3086,7 +3096,14 @@ final class Workspace: Identifiable, ObservableObject {
}
if let browserPanel = panels[panelId] as? BrowserPanel {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
// Keep browser find focus behavior aligned with terminal find behavior.
// When switching back to a pane with an already-open find bar, reassert
// focus to that field instead of leaving first responder stale.
if browserPanel.searchState != nil {
browserPanel.startFind()
} else {
maybeAutoFocusBrowserAddressBarOnPanelFocus(browserPanel, trigger: trigger)
}
}
}

View file

@ -2825,6 +2825,8 @@ struct SettingsView: View {
@AppStorage(QuitWarningSettings.warnBeforeQuitKey) private var warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
@AppStorage(CommandPaletteRenameSelectionSettings.selectAllOnFocusKey)
private var commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey)
private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
@AppStorage(SidebarBranchLayoutSettings.key) private var sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout
@ -4107,6 +4109,8 @@ struct SettingsView: View {
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
warnBeforeQuitShortcut = QuitWarningSettings.defaultWarnBeforeQuit
commandPaletteRenameSelectAllOnFocus = CommandPaletteRenameSelectionSettings.defaultSelectAllOnFocus
ShortcutHintDebugSettings.resetVisibilityDefaults()
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
sidebarBranchVerticalLayout = SidebarBranchLayoutSettings.defaultVerticalLayout

View file

@ -8,6 +8,39 @@ import XCTest
@MainActor
final class AppDelegateShortcutRoutingTests: XCTestCase {
private var savedShortcutsByAction: [KeyboardShortcutSettings.Action: StoredShortcut] = [:]
private var actionsWithPersistedShortcut: Set<KeyboardShortcutSettings.Action> = []
override func setUp() {
super.setUp()
actionsWithPersistedShortcut = Set(
KeyboardShortcutSettings.Action.allCases.filter {
UserDefaults.standard.object(forKey: $0.defaultsKey) != nil
}
)
savedShortcutsByAction = Dictionary(
uniqueKeysWithValues: actionsWithPersistedShortcut.map { action in
(action, KeyboardShortcutSettings.shortcut(for: action))
}
)
KeyboardShortcutSettings.resetAll()
}
override func tearDown() {
AppDelegate.shared?.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
AppDelegate.shared?.dismissNotificationsPopoverIfShown()
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
for action in KeyboardShortcutSettings.Action.allCases {
if actionsWithPersistedShortcut.contains(action),
let savedShortcut = savedShortcutsByAction[action] {
KeyboardShortcutSettings.setShortcut(savedShortcut, for: action)
} else {
KeyboardShortcutSettings.resetShortcut(for: action)
}
}
super.tearDown()
}
func testCmdNUsesEventWindowContextWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -311,6 +344,910 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testCmdPhysicalIWithDvorakCharactersDoesNotTriggerShowNotifications() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(action: .showNotifications) {
// Dvorak: physical ANSI "I" key can produce the character "c".
// This should behave like Cmd+C (copy), not match the Cmd+I app shortcut.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "c",
charactersIgnoringModifiers: "c",
isARepeat: false,
keyCode: 34 // kVK_ANSI_I
) else {
XCTFail("Failed to construct Dvorak Cmd+C event on physical ANSI I key")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdPhysicalPWithDvorakCharactersDoesNotTriggerCommandPaletteSwitcher() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
let switcherExpectation = expectation(description: "Cmd+L should not request command palette switcher")
switcherExpectation.isInverted = true
let token = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { _ in
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }
// Dvorak: physical ANSI "P" key can produce "l".
// This should behave as Cmd+L, not as physical Cmd+P.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "l",
charactersIgnoringModifiers: "l",
isARepeat: false,
keyCode: 35 // kVK_ANSI_P
) else {
XCTFail("Failed to construct Dvorak Cmd+L event on physical ANSI P key")
return
}
#if DEBUG
_ = appDelegate.debugHandleCustomShortcut(event: event)
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
wait(for: [switcherExpectation], timeout: 0.15)
}
func testCmdPWithCapsLockStillTriggersCommandPaletteSwitcher() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
let switcherExpectation = expectation(description: "Cmd+P with Caps Lock should request command palette switcher")
let token = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { _ in
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .capsLock],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "p",
charactersIgnoringModifiers: "p",
isARepeat: false,
keyCode: 35 // kVK_ANSI_P
) else {
XCTFail("Failed to construct Cmd+P + Caps Lock event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
wait(for: [switcherExpectation], timeout: 0.15)
}
func testCmdPFallsBackToANSIKeyCodeWhenCharactersAndLayoutTranslationAreUnavailable() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
appDelegate.shortcutLayoutCharacterProvider = { _, _ in nil }
defer {
appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
}
let switcherExpectation = expectation(description: "Cmd+P with unavailable characters should request command palette switcher")
let token = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { _ in
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "",
charactersIgnoringModifiers: "",
isARepeat: false,
keyCode: 35 // kVK_ANSI_P
) else {
XCTFail("Failed to construct Cmd+P event with unavailable characters")
return
}
XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event))
wait(for: [switcherExpectation], timeout: 0.15)
}
func testCmdPDoesNotFallbackToANSIKeyCodeWhenLayoutTranslationProvidesDifferentLetter() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
appDelegate.shortcutLayoutCharacterProvider = { _, _ in "b" }
defer {
appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
}
let switcherExpectation = expectation(description: "Non-P layout translation should not request command palette switcher")
switcherExpectation.isInverted = true
let token = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { _ in
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "",
charactersIgnoringModifiers: "",
isARepeat: false,
keyCode: 35 // kVK_ANSI_P
) else {
XCTFail("Failed to construct Cmd+P event with unavailable characters")
return
}
_ = appDelegate.handleBrowserSurfaceKeyEquivalent(event)
wait(for: [switcherExpectation], timeout: 0.15)
}
func testCmdPFallsBackToCommandAwareLayoutTranslationWhenCharactersAreUnavailable() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
appDelegate.shortcutLayoutCharacterProvider = { keyCode, modifierFlags in
guard keyCode == 35 else { return nil } // kVK_ANSI_P
return modifierFlags.contains(.command) ? "p" : "r"
}
defer {
appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:)
}
let switcherExpectation = expectation(description: "Command-aware layout translation should request command palette switcher")
let token = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { _ in
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "",
charactersIgnoringModifiers: "",
isARepeat: false,
keyCode: 35 // kVK_ANSI_P
) else {
XCTFail("Failed to construct Cmd+P event with unavailable characters")
return
}
XCTAssertTrue(appDelegate.handleBrowserSurfaceKeyEquivalent(event))
wait(for: [switcherExpectation], timeout: 0.15)
}
func testCmdShiftPhysicalPWithDvorakCharactersDoesNotTriggerCommandPalette() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
let paletteExpectation = expectation(description: "Cmd+Shift+L should not request command palette")
paletteExpectation.isInverted = true
let token = NotificationCenter.default.addObserver(
forName: .commandPaletteRequested,
object: nil,
queue: nil
) { _ in
paletteExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(token) }
// Dvorak: physical ANSI "P" key can produce "l".
// This should behave as Cmd+Shift+L, not as physical Cmd+Shift+P.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .shift],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "l",
charactersIgnoringModifiers: "l",
isARepeat: false,
keyCode: 35 // kVK_ANSI_P
) else {
XCTFail("Failed to construct Dvorak Cmd+Shift+L event on physical ANSI P key")
return
}
#if DEBUG
_ = appDelegate.debugHandleCustomShortcut(event: event)
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
wait(for: [paletteExpectation], timeout: 0.15)
}
func testCmdOptionPhysicalTWithDvorakCharactersDoesNotTriggerCloseOtherTabsShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
// Dvorak: physical ANSI "T" key can produce "y".
// This should not match the Cmd+Option+T app shortcut.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .option],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "y",
charactersIgnoringModifiers: "y",
isARepeat: false,
keyCode: 17 // kVK_ANSI_T
) else {
XCTFail("Failed to construct Dvorak Cmd+Option+Y event on physical ANSI T key")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
func testCmdPhysicalWWithDvorakCharactersDoesNotTriggerClosePanelShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId),
let manager = appDelegate.tabManagerFor(windowId: windowId),
let workspace = manager.selectedWorkspace else {
XCTFail("Expected test window and workspace")
return
}
let panelCountBefore = workspace.panels.count
// Dvorak: physical ANSI "W" key can produce ",".
// This should not match the Cmd+W close-panel shortcut.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: ",",
charactersIgnoringModifiers: ",",
isARepeat: false,
keyCode: 13 // kVK_ANSI_W
) else {
XCTFail("Failed to construct Dvorak Cmd+, event on physical ANSI W key")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(workspace.panels.count, panelCountBefore)
}
func testCmdIStillTriggersShowNotificationsShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(action: .showNotifications) {
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "i",
charactersIgnoringModifiers: "i",
isARepeat: false,
keyCode: 34 // kVK_ANSI_I
) else {
XCTFail("Failed to construct Cmd+I event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdUnshiftedSymbolDoesNotMatchDigitShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(
action: .showNotifications,
shortcut: StoredShortcut(key: "8", command: true, shift: false, option: false, control: false)
) {
// Some non-US layouts can produce "*" without Shift.
// This must not be coerced into "8" for a Cmd+8 shortcut match.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "*",
charactersIgnoringModifiers: "*",
isARepeat: false,
keyCode: 30 // kVK_ANSI_RightBracket
) else {
XCTFail("Failed to construct Cmd+* event")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdDigitShortcutFallsBackByKeyCodeOnSymbolFirstLayouts() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(
action: .showNotifications,
shortcut: StoredShortcut(key: "1", command: true, shift: false, option: false, control: false)
) {
// Symbol-first layouts (for example AZERTY) can report "&" for the ANSI 1 key.
// Cmd+1 shortcuts should still match via keyCode fallback in this case.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "&",
charactersIgnoringModifiers: "&",
isARepeat: false,
keyCode: 18 // kVK_ANSI_1
) else {
XCTFail("Failed to construct Cmd+& event on ANSI 1 key")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdShiftNonDigitKeySymbolDoesNotMatchShiftedDigitShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(
action: .showNotifications,
shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false)
) {
// Avoid unrelated default Cmd+Shift+] handling for this assertion.
withTemporaryShortcut(
action: .nextSurface,
shortcut: StoredShortcut(key: "x", command: true, shift: true, option: false, control: false)
) {
// On some non-US layouts, Shift+RightBracket can produce "*".
// This must not be interpreted as Shift+8.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .shift],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "*",
charactersIgnoringModifiers: "*",
isARepeat: false,
keyCode: 30 // kVK_ANSI_RightBracket
) else {
XCTFail("Failed to construct Cmd+Shift+* event from non-digit key")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
}
func testCmdShiftDigitShortcutMatchesShiftedDigitKey() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(
action: .showNotifications,
shortcut: StoredShortcut(key: "8", command: true, shift: true, option: false, control: false)
) {
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .shift],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "*",
charactersIgnoringModifiers: "*",
isARepeat: false,
keyCode: 28 // kVK_ANSI_8
) else {
XCTFail("Failed to construct Cmd+Shift+8 event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdShiftQuestionMarkMatchesSlashShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(
action: .triggerFlash,
shortcut: StoredShortcut(key: "/", command: true, shift: true, option: false, control: false)
) {
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .shift],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "?",
charactersIgnoringModifiers: "?",
isARepeat: false,
keyCode: 44 // kVK_ANSI_Slash
) else {
XCTFail("Failed to construct Cmd+Shift+/ event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdShiftISOAngleBracketDoesNotMatchCommaShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(
action: .showNotifications,
shortcut: StoredShortcut(key: ",", command: true, shift: true, option: false, control: false)
) {
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .shift],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "<",
charactersIgnoringModifiers: "<",
isARepeat: false,
keyCode: 10 // kVK_ISO_Section
) else {
XCTFail("Failed to construct Cmd+Shift+< event from ISO key")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdShiftRightBracketCanFallbackByKeyCodeOnNonUSLayouts() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
withTemporaryShortcut(action: .nextSurface) {
// Non-US layouts can report "*" (or other symbols) for kVK_ANSI_RightBracket with Shift.
// Shortcut matching should still allow Cmd+Shift+] via keyCode fallback.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command, .shift],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "*",
charactersIgnoringModifiers: "*",
isARepeat: false,
keyCode: 30 // kVK_ANSI_RightBracket
) else {
XCTFail("Failed to construct non-US Cmd+Shift+] event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
}
func testCmdPhysicalOWithDvorakCharactersTriggersRenameTabShortcut() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
let renameTabExpectation = expectation(description: "Expected rename tab request for semantic Cmd+R")
var observedRenameTabWindow: NSWindow?
let renameTabToken = NotificationCenter.default.addObserver(
forName: .commandPaletteRenameTabRequested,
object: nil,
queue: nil
) { notification in
observedRenameTabWindow = notification.object as? NSWindow
renameTabExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(renameTabToken) }
let switcherExpectation = expectation(description: "Cmd+R should not trigger command palette switcher")
switcherExpectation.isInverted = true
let switcherToken = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { _ in
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(switcherToken) }
withTemporaryShortcut(action: .renameTab) {
// Dvorak: physical ANSI "O" key can produce "r".
// This should behave as semantic Cmd+R (rename tab), not Cmd+P.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "r",
charactersIgnoringModifiers: "r",
isARepeat: false,
keyCode: 31 // kVK_ANSI_O
) else {
XCTFail("Failed to construct Dvorak Cmd+R event on physical ANSI O key")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
}
wait(for: [renameTabExpectation, switcherExpectation], timeout: 1.0)
XCTAssertEqual(observedRenameTabWindow?.windowNumber, window.windowNumber)
}
func testCmdPhysicalRWithDvorakCharactersTriggersCommandPaletteSwitcher() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let windowId = appDelegate.createMainWindow()
defer { closeWindow(withId: windowId) }
guard let window = window(withId: windowId) else {
XCTFail("Expected test window")
return
}
let switcherExpectation = expectation(description: "Expected command palette switcher request for semantic Cmd+P")
var observedSwitcherWindow: NSWindow?
let switcherToken = NotificationCenter.default.addObserver(
forName: .commandPaletteSwitcherRequested,
object: nil,
queue: nil
) { notification in
observedSwitcherWindow = notification.object as? NSWindow
switcherExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(switcherToken) }
let renameTabExpectation = expectation(description: "Physical R on Dvorak should not trigger rename tab")
renameTabExpectation.isInverted = true
let renameTabToken = NotificationCenter.default.addObserver(
forName: .commandPaletteRenameTabRequested,
object: nil,
queue: nil
) { _ in
renameTabExpectation.fulfill()
}
defer { NotificationCenter.default.removeObserver(renameTabToken) }
// Dvorak: physical ANSI "R" key can produce "p".
// This should behave as semantic Cmd+P (palette switcher), not Cmd+R.
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [.command],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "p",
charactersIgnoringModifiers: "p",
isARepeat: false,
keyCode: 15 // kVK_ANSI_R
) else {
XCTFail("Failed to construct Dvorak Cmd+P event on physical ANSI R key")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
wait(for: [switcherExpectation, renameTabExpectation], timeout: 1.0)
XCTAssertEqual(observedSwitcherWindow?.windowNumber, window.windowNumber)
}
func testCmdShiftRRequestsRenameWorkspaceInCommandPalette() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
@ -684,6 +1621,9 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
return
}
window.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
#if DEBUG
XCTAssertTrue(
appDelegate.debugSetCommandPalettePendingOpenAge(window: window, age: 20.0),
@ -1210,6 +2150,24 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
)
}
private func withTemporaryShortcut(
action: KeyboardShortcutSettings.Action,
shortcut: StoredShortcut? = nil,
_ body: () -> Void
) {
let hadPersistedShortcut = UserDefaults.standard.object(forKey: action.defaultsKey) != nil
let originalShortcut = KeyboardShortcutSettings.shortcut(for: action)
defer {
if hadPersistedShortcut {
KeyboardShortcutSettings.setShortcut(originalShortcut, for: action)
} else {
KeyboardShortcutSettings.resetShortcut(for: action)
}
}
KeyboardShortcutSettings.setShortcut(shortcut ?? action.defaultShortcut, for: action)
body()
}
private func assertEscapeKeyUpIsConsumedAfterCommandPaletteOpenRequest(
_ openRequest: (_ appDelegate: AppDelegate, _ window: NSWindow) -> Void,
file: StaticString = #filePath,

File diff suppressed because it is too large Load diff

View file

@ -423,6 +423,180 @@ final class BrowserPaneNavigationKeybindUITests: XCTestCase {
)
}
func testCmdOptionPaneSwitchPreservesFindFieldFocus() {
runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: false)
}
func testCmdCtrlPaneSwitchPreservesFindFieldFocus() {
runFindFocusPersistenceScenario(route: .cmdCtrlLetters, useAutofocusRacePage: false)
}
func testCmdOptionPaneSwitchPreservesFindFieldFocusDuringPageAutofocusRace() {
runFindFocusPersistenceScenario(route: .cmdOptionArrows, useAutofocusRacePage: true)
}
private enum FindFocusRoute {
case cmdOptionArrows
case cmdCtrlLetters
}
private func runFindFocusPersistenceScenario(route: FindFocusRoute, useAutofocusRacePage: Bool) {
let app = XCUIApplication()
app.launchEnvironment["CMUX_SOCKET_PATH"] = socketPath
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_RECORD_ONLY"] = "1"
app.launchEnvironment["CMUX_UI_TEST_GOTO_SPLIT_PATH"] = dataPath
if route == .cmdCtrlLetters {
app.launchEnvironment["CMUX_UI_TEST_FOCUS_SHORTCUTS"] = "1"
}
launchAndEnsureForeground(app)
let window = app.windows.firstMatch
XCTAssertTrue(window.waitForExistence(timeout: 10.0), "Expected main window to exist")
// Repro setup: split, open browser split, navigate to example.com.
app.typeKey("d", modifierFlags: [.command])
focusRightPaneForFindScenario(app, route: route)
app.typeKey("l", modifierFlags: [.command, .shift])
let omnibar = app.textFields["BrowserOmnibarTextField"].firstMatch
XCTAssertTrue(omnibar.waitForExistence(timeout: 8.0), "Expected browser omnibar after Cmd+Shift+L")
app.typeKey("a", modifierFlags: [.command])
app.typeKey(XCUIKeyboardKey.delete.rawValue, modifierFlags: [])
if useAutofocusRacePage {
app.typeText(autofocusRacePageURL)
} else {
app.typeText("example.com")
}
app.typeKey(XCUIKeyboardKey.return.rawValue, modifierFlags: [])
if useAutofocusRacePage {
XCTAssertTrue(
waitForOmnibarToContain(omnibar, value: "data:text/html", timeout: 8.0),
"Expected browser navigation to data URL before running find flow. value=\(String(describing: omnibar.value))"
)
} else {
XCTAssertTrue(
waitForOmnibarToContainExampleDomain(omnibar, timeout: 8.0),
"Expected browser navigation to example domain before running find flow. value=\(String(describing: omnibar.value))"
)
}
// Left terminal: Cmd+F then type "la".
focusLeftPaneForFindScenario(app, route: route)
XCTAssertTrue(
waitForDataMatch(timeout: 6.0) { data in
data["focusedPanelKind"] == "terminal"
},
"Expected left terminal pane to be focused before terminal find. data=\(String(describing: loadData()))"
)
app.typeKey("f", modifierFlags: [.command])
app.typeText("la")
// Right browser: Cmd+F then type "am".
focusRightPaneForFindScenario(app, route: route)
XCTAssertTrue(
waitForDataMatch(timeout: 6.0) { data in
data["lastMoveDirection"] == "right"
&& data["focusedPanelKind"] == "browser"
&& data["terminalFindNeedle"] == "la"
},
"Expected terminal find query to persist as 'la' after focusing browser pane. data=\(String(describing: loadData()))"
)
app.typeKey("f", modifierFlags: [.command])
app.typeText("am")
if useAutofocusRacePage {
XCTAssertTrue(
waitForOmnibarToContain(omnibar, value: "#focused", timeout: 5.0),
"Expected autofocus race page to signal focus handoff via URL hash. value=\(String(describing: omnibar.value))"
)
}
// Left terminal: typing should keep going into terminal find field.
focusLeftPaneForFindScenario(app, route: route)
XCTAssertTrue(
waitForDataMatch(timeout: 6.0) { data in
data["lastMoveDirection"] == "left"
&& data["focusedPanelKind"] == "terminal"
&& data["browserFindNeedle"] == "am"
},
"Expected browser find query to persist as 'am' after returning left. data=\(String(describing: loadData()))"
)
app.typeText("foo")
// Right browser: typing should keep going into browser find field.
focusRightPaneForFindScenario(app, route: route)
XCTAssertTrue(
waitForDataMatch(timeout: 6.0) { data in
data["lastMoveDirection"] == "right"
&& data["focusedPanelKind"] == "browser"
&& data["terminalFindNeedle"] == "lafoo"
},
"Expected terminal find query to stay focused and become 'lafoo'. data=\(String(describing: loadData()))"
)
app.typeText("do")
// Move left once more so the recorder captures browser find state after typing.
focusLeftPaneForFindScenario(app, route: route)
XCTAssertTrue(
waitForDataMatch(timeout: 6.0) { data in
data["lastMoveDirection"] == "left"
&& data["focusedPanelKind"] == "terminal"
&& data["browserFindNeedle"] == "amdo"
},
"Expected browser find query to stay focused and become 'amdo'. data=\(String(describing: loadData()))"
)
}
private func focusLeftPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) {
switch route {
case .cmdOptionArrows:
app.typeKey(XCUIKeyboardKey.leftArrow.rawValue, modifierFlags: [.command, .option])
case .cmdCtrlLetters:
app.typeKey("h", modifierFlags: [.command, .control])
}
}
private func focusRightPaneForFindScenario(_ app: XCUIApplication, route: FindFocusRoute) {
switch route {
case .cmdOptionArrows:
app.typeKey(XCUIKeyboardKey.rightArrow.rawValue, modifierFlags: [.command, .option])
case .cmdCtrlLetters:
app.typeKey("l", modifierFlags: [.command, .control])
}
}
private func waitForOmnibarToContainExampleDomain(_ omnibar: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = (omnibar.value as? String) ?? ""
if value.contains("example.com") || value.contains("example.org") {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
let value = (omnibar.value as? String) ?? ""
return value.contains("example.com") || value.contains("example.org")
}
private func waitForOmnibarToContain(_ omnibar: XCUIElement, value expectedSubstring: String, timeout: TimeInterval) -> Bool {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
let value = (omnibar.value as? String) ?? ""
if value.contains(expectedSubstring) {
return true
}
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
}
let value = (omnibar.value as? String) ?? ""
return value.contains(expectedSubstring)
}
private var autofocusRacePageURL: String {
"data:text/html,%3Cinput%20id%3D%22q%22%3E%3Cscript%3EsetTimeout%28function%28%29%7Bdocument.getElementById%28%22q%22%29.focus%28%29%3Blocation.hash%3D%22focused%22%3B%7D%2C700%29%3B%3C%2Fscript%3E"
}
private func launchAndEnsureForeground(_ app: XCUIApplication, timeout: TimeInterval = 12.0) {
app.launch()
XCTAssertTrue(

Binary file not shown.

After

Width:  |  Height:  |  Size: 829 KiB

View file

@ -10,19 +10,21 @@ Use this skill for browser tasks inside cmux webviews.
## Core Workflow
1. Open or target a browser surface.
2. Snapshot (`--interactive`) to get fresh element refs.
3. Act with refs (`click`, `fill`, `type`, `select`, `press`).
4. Wait for state changes.
5. Re-snapshot after DOM/navigation changes.
2. Verify navigation with `get url` before waiting or snapshotting.
3. Snapshot (`--interactive`) to get fresh element refs.
4. Act with refs (`click`, `fill`, `type`, `select`, `press`).
5. Wait for state changes.
6. Re-snapshot after DOM/navigation changes.
```bash
cmux browser open https://example.com --json
cmux --json browser open https://example.com
# use returned surface ref, for example: surface:7
cmux browser surface:7 get url
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
cmux browser surface:7 fill e1 "hello"
cmux browser surface:7 click e2 --snapshot-after --json
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux --json browser surface:7 click e2 --snapshot-after
cmux browser surface:7 snapshot --interactive
```
@ -58,11 +60,13 @@ cmux browser <surface> wait --function "document.readyState === 'complete'" --ti
### Form Submit
```bash
cmux browser open https://example.com/signup --json
cmux --json browser open https://example.com/signup
cmux browser surface:7 get url
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
cmux browser surface:7 fill e1 "Jane Doe"
cmux browser surface:7 fill e2 "jane@example.com"
cmux browser surface:7 click e3 --snapshot-after --json
cmux --json browser surface:7 click e3 --snapshot-after
cmux browser surface:7 wait --url-contains "/welcome" --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
```
@ -77,13 +81,16 @@ cmux browser surface:7 get value e11 --json
### Stable Agent Loop (Recommended)
```bash
# snapshot -> action -> wait -> snapshot
cmux browser surface:7 snapshot --interactive
cmux browser surface:7 click e5 --snapshot-after --json
# navigate -> verify -> wait -> snapshot -> action -> snapshot
cmux browser surface:7 get url
cmux browser surface:7 wait --load-state complete --timeout-ms 15000
cmux browser surface:7 snapshot --interactive
cmux --json browser surface:7 click e5 --snapshot-after
cmux browser surface:7 snapshot --interactive
```
If `get url` is empty or `about:blank`, navigate first instead of waiting on load state.
## Deep-Dive References
| Reference | When to Use |
@ -114,3 +121,21 @@ These commands currently return `not_supported` because they rely on Chrome/CDP-
- low-level raw input injection
Use supported high-level commands (`click`, `fill`, `press`, `scroll`, `wait`, `snapshot`) instead.
## Troubleshooting
### `js_error` on `snapshot --interactive` or `eval`
Some complex pages can reject or break the JavaScript used for rich snapshots and ad-hoc evaluation.
Recovery steps:
```bash
cmux browser surface:7 get url
cmux browser surface:7 get text body
cmux browser surface:7 get html body
```
- Use `get url` first so you know whether the page actually navigated.
- Fall back to `get text body` or `get html body` when `snapshot --interactive` or `eval` returns `js_error`.
- If the page is still failing, navigate to a simpler intermediate page, then retry the task from there.

View file

@ -11,7 +11,7 @@ This maps common `agent-browser` usage to `cmux browser` usage.
- `agent-browser fill <ref> <text>` -> `cmux browser <surface> fill <ref> <text>`
- `agent-browser type <ref> <text>` -> `cmux browser <surface> type <ref> <text>`
- `agent-browser select <ref> <value>` -> `cmux browser <surface> select <ref> <value>`
- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref>`
- `agent-browser get text <ref>` -> `cmux browser <surface> get text <ref-or-selector>`
- `agent-browser get url` -> `cmux browser <surface> get url`
- `agent-browser get title` -> `cmux browser <surface> get title`
@ -34,7 +34,13 @@ cmux browser <surface> get url|title
```bash
cmux browser <surface> snapshot --interactive
cmux browser <surface> snapshot --interactive --compact --max-depth 3
cmux browser <surface> get text|html|value|attr|count|box|styles ...
cmux browser <surface> get text body
cmux browser <surface> get html body
cmux browser <surface> get value "#email"
cmux browser <surface> get attr "#email" --attr placeholder
cmux browser <surface> get count ".row"
cmux browser <surface> get box "#submit"
cmux browser <surface> get styles "#submit" --property color
cmux browser <surface> eval '<js>'
```

View file

@ -10,6 +10,7 @@ if [ -f "$STATE_FILE" ]; then
fi
cmux browser "$SURFACE" goto "$DASHBOARD_URL"
cmux browser "$SURFACE" get url
cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000
cmux browser "$SURFACE" snapshot --interactive

View file

@ -5,6 +5,7 @@ URL="${1:-https://example.com/form}"
SURFACE="${2:-surface:1}"
cmux browser "$SURFACE" goto "$URL"
cmux browser "$SURFACE" get url
cmux browser "$SURFACE" wait --load-state complete --timeout-ms 15000
cmux browser "$SURFACE" snapshot --interactive

View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Shared helpers for static regression tests."""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
def repo_root() -> Path:
git = shutil.which("git")
if git is None:
return Path(__file__).resolve().parents[1]
try:
result = subprocess.run(
[git, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
timeout=2,
)
except (subprocess.TimeoutExpired, OSError):
return Path(__file__).resolve().parents[1]
if result.returncode == 0:
return Path(result.stdout.strip())
return Path(__file__).resolve().parents[1]
def extract_block(source: str, signature: str) -> str:
# Targeted helper for this regression suite: assumes braces in the matched
# block are structural (not inside strings/comments/character literals).
start = source.find(signature)
if start < 0:
raise ValueError(f"Missing signature: {signature}")
brace_start = source.find("{", start)
if brace_start < 0:
raise ValueError(f"Missing opening brace for: {signature}")
depth = 0
for idx in range(brace_start, len(source)):
char = source[idx]
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return source[brace_start : idx + 1]
raise ValueError(f"Unbalanced braces for: {signature}")

View file

@ -0,0 +1,203 @@
#!/usr/bin/env python3
"""Regression guards for browser Cmd+F overlay layering in portal mode."""
from __future__ import annotations
from regression_helpers import extract_block, repo_root
def main() -> int:
root = repo_root()
view_path = root / "Sources" / "Panels" / "BrowserPanelView.swift"
panel_path = root / "Sources" / "Panels" / "BrowserPanel.swift"
overlay_path = root / "Sources" / "Find" / "BrowserSearchOverlay.swift"
source = view_path.read_text(encoding="utf-8")
panel_source = panel_path.read_text(encoding="utf-8")
overlay_source = overlay_path.read_text(encoding="utf-8")
failures: list[str] = []
try:
browser_panel_view_block = extract_block(
source, "struct BrowserPanelView: View"
)
except ValueError as error:
failures.append(str(error))
browser_panel_view_block = ""
try:
body_block = extract_block(browser_panel_view_block, "var body: some View")
except ValueError as error:
failures.append(str(error))
body_block = ""
fallback_signature = (
"if !panel.shouldRenderWebView, let searchState = panel.searchState {"
)
fallback_block = ""
if body_block:
try:
fallback_block = extract_block(body_block, fallback_signature)
except ValueError:
failures.append(
"BrowserPanelView must provide BrowserSearchOverlay fallback for new-tab state "
"(when WKWebView is not mounted)"
)
if fallback_block and "BrowserSearchOverlay(" not in fallback_block:
failures.append(
"BrowserPanelView fallback branch must mount BrowserSearchOverlay for new-tab state"
)
try:
webview_repr_block = extract_block(
source, "struct WebViewRepresentable: NSViewRepresentable"
)
except ValueError as error:
failures.append(str(error))
webview_repr_block = ""
if webview_repr_block:
if "let browserSearchState: BrowserSearchState?" not in webview_repr_block:
failures.append(
"WebViewRepresentable must include browserSearchState so Cmd+F state changes trigger updates"
)
if (
"var searchOverlayHostingView: NSHostingView<BrowserSearchOverlay>?"
not in webview_repr_block
):
failures.append(
"WebViewRepresentable.Coordinator must own a BrowserSearchOverlay hosting view"
)
if "private static func updateSearchOverlay(" not in webview_repr_block:
failures.append(
"WebViewRepresentable must define updateSearchOverlay helper"
)
if "containerView: webView.superview" not in webview_repr_block:
failures.append(
"Portal updates must sync BrowserSearchOverlay against the web view container"
)
if "removeSearchOverlay(from: coordinator)" not in webview_repr_block:
failures.append(
"WebViewRepresentable must remove browser search overlays during teardown/rebind"
)
if "browserSearchState: panel.searchState" not in source:
failures.append(
"BrowserPanelView must pass panel.searchState into WebViewRepresentable"
)
try:
update_ns_view_block = extract_block(
webview_repr_block, "func updateNSView(_ nsView: NSView, context: Context)"
)
except ValueError as error:
failures.append(str(error))
update_ns_view_block = ""
if "updateSearchOverlay(" in update_ns_view_block:
failures.append(
"updateNSView must not re-run updateSearchOverlay outside portal lifecycle paths"
)
try:
suppress_focus_block = extract_block(
panel_source, "func shouldSuppressWebViewFocus() -> Bool"
)
except ValueError as error:
failures.append(str(error))
suppress_focus_block = ""
if "if searchState != nil {" not in suppress_focus_block:
failures.append(
"BrowserPanel.shouldSuppressWebViewFocus must suppress focus while find-in-page is active"
)
try:
start_find_block = extract_block(panel_source, "func startFind()")
except ValueError as error:
failures.append(str(error))
start_find_block = ""
if start_find_block:
if "postBrowserSearchFocusNotification()" not in start_find_block:
failures.append(
"BrowserPanel.startFind must publish browserSearchFocus notifications"
)
if "DispatchQueue.main.async {" not in start_find_block:
failures.append(
"BrowserPanel.startFind must re-post focus on next runloop to avoid mount races"
)
if "DispatchQueue.main.asyncAfter" not in start_find_block:
failures.append(
"BrowserPanel.startFind must re-post focus shortly after to avoid portal mount races"
)
try:
init_block = extract_block(panel_source, "init(workspaceId: UUID")
except ValueError as error:
failures.append(str(error))
init_block = ""
if init_block:
if (
"self?.searchState = nil" in init_block
or "self.searchState = nil" in init_block
):
failures.append(
"BrowserPanel navigation callbacks must not clear searchState entirely to avoid losing find bar focus"
)
if "restoreFindStateAfterNavigation(replaySearch: true)" not in init_block:
failures.append(
"BrowserPanel.didFinish must preserve find state and replay search on the new page"
)
if "restoreFindStateAfterNavigation(replaySearch: false)" not in init_block:
failures.append(
"BrowserPanel.didFailNavigation must preserve find state without replaying search"
)
try:
restore_find_state_block = extract_block(
panel_source, "private func restoreFindStateAfterNavigation(replaySearch: Bool)"
)
except ValueError as error:
failures.append(str(error))
restore_find_state_block = ""
if restore_find_state_block:
if "state.total = nil" not in restore_find_state_block:
failures.append(
"BrowserPanel restoreFindStateAfterNavigation must clear stale find total count"
)
if "state.selected = nil" not in restore_find_state_block:
failures.append(
"BrowserPanel restoreFindStateAfterNavigation must clear stale selected match"
)
if "if replaySearch, !state.needle.isEmpty {" not in restore_find_state_block:
failures.append(
"BrowserPanel restoreFindStateAfterNavigation must only replay search for successful navigations"
)
if "postBrowserSearchFocusNotification()" not in restore_find_state_block:
failures.append(
"BrowserPanel restoreFindStateAfterNavigation must reassert find field focus"
)
if "private func requestSearchFieldFocus(" not in overlay_source:
failures.append(
"BrowserSearchOverlay must define requestSearchFieldFocus retry helper"
)
if "requestSearchFieldFocus()" not in overlay_source:
failures.append(
"BrowserSearchOverlay must request text focus from appear/notification paths"
)
if failures:
print("FAIL: browser find overlay portal regression guards failed")
for failure in failures:
print(f" - {failure}")
return 1
print("PASS: browser find overlay remains mounted in portal-hosted AppKit layer")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
E2E: focusing a panel clears its notification and triggers a flash.
E2E: focusing a panel preserves its notification and triggers a flash.
Note: This uses the socket focus command (no assistive access needed).
"""
@ -74,8 +74,12 @@ def main() -> int:
client.send("x")
time.sleep(0.2)
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification did not become read after focus")
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification became read after focus")
return 1
items = client.list_notifications()
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
print("FAIL: Notification did not remain present and unread after focus")
return 1
final_flash = client.flash_count(term_b)
@ -93,7 +97,7 @@ def main() -> int:
except Exception:
pass
print("PASS: Focus clears notification and flashes panel")
print("PASS: Focus preserves notification and flashes panel")
return 0
except (cmuxError, RuntimeError) as exc:
print(f"FAIL: {exc}")

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Regression guard for issue #666 (sidebar branch stuck after checkout)."""
from __future__ import annotations
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
result = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
)
if result.returncode == 0:
return Path(result.stdout.strip())
return Path.cwd()
def require(content: str, needle: str, message: str, failures: list[str]) -> None:
if needle not in content:
failures.append(message)
def main() -> int:
repo_root = get_repo_root()
zsh_path = repo_root / "Resources" / "shell-integration" / "cmux-zsh-integration.zsh"
bash_path = repo_root / "Resources" / "shell-integration" / "cmux-bash-integration.bash"
required_paths = [zsh_path, bash_path]
missing_paths = [str(path) for path in required_paths if not path.exists()]
if missing_paths:
print("Missing expected files:")
for path in missing_paths:
print(f" - {path}")
return 1
zsh_content = zsh_path.read_text(encoding="utf-8")
bash_content = bash_path.read_text(encoding="utf-8")
failures: list[str] = []
require(
zsh_content,
"_CMUX_GIT_HEAD_SIGNATURE",
"zsh integration is missing git HEAD signature tracking",
failures,
)
require(
zsh_content,
"_cmux_git_head_signature",
"zsh integration is missing git HEAD signature helper",
failures,
)
require(
zsh_content,
'"$head_signature" != "$_CMUX_GIT_HEAD_SIGNATURE"',
"zsh integration no longer compares git HEAD signatures",
failures,
)
require(
zsh_content,
"_CMUX_GIT_FORCE=1",
"zsh integration no longer forces git probe refresh on HEAD changes",
failures,
)
require(
bash_content,
"_CMUX_GIT_HEAD_SIGNATURE",
"bash integration is missing git HEAD signature tracking",
failures,
)
require(
bash_content,
"_cmux_git_head_signature",
"bash integration is missing git HEAD signature helper",
failures,
)
require(
bash_content,
"git_head_changed=1",
"bash integration no longer flags HEAD changes for immediate refresh",
failures,
)
require(
bash_content,
'|| "$git_head_changed" == "1"',
"bash integration no longer restarts running git probes on HEAD change",
failures,
)
if failures:
print("FAIL: issue #666 regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: issue #666 checkout refresh guards are present")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Regression guard for issue #952 (flaky CLI socket connections)."""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
def get_repo_root() -> Path:
"""Return the repository root for source inspections."""
fallback_root = Path(__file__).resolve().parents[1]
git_path = shutil.which("git")
if git_path is None:
return fallback_root
try:
result = subprocess.run(
[git_path, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
except OSError:
return fallback_root
if result.returncode == 0:
return Path(result.stdout.strip())
return fallback_root
def require(content: str, needle: str, message: str, failures: list[str], *, regex: bool = False) -> None:
"""Record a failure when a required source pattern is missing."""
matched = re.search(needle, content, re.MULTILINE) is not None if regex else needle in content
if not matched:
failures.append(message)
def collect_failures() -> list[str]:
"""Collect missing source-level guards for the socket listener recovery fix."""
repo_root = get_repo_root()
terminal_controller_path = repo_root / "Sources" / "TerminalController.swift"
app_delegate_path = repo_root / "Sources" / "AppDelegate.swift"
failures: list[str] = []
missing_paths = [
str(path) for path in [terminal_controller_path, app_delegate_path] if not path.exists()
]
if missing_paths:
for path in missing_paths:
failures.append(f"Missing expected file: {path}")
return failures
terminal_controller = terminal_controller_path.read_text(encoding="utf-8")
app_delegate = app_delegate_path.read_text(encoding="utf-8")
require(
terminal_controller,
"let socketProbePerformed: Bool",
"Socket health snapshot no longer tracks whether connectability was probed",
failures,
)
require(
terminal_controller,
"let socketConnectable: Bool?",
"Socket health snapshot no longer distinguishes unprobed vs connectable sockets",
failures,
)
require(
terminal_controller,
"let socketConnectErrno: Int32?",
"Socket health snapshot no longer preserves probe errno",
failures,
)
require(
terminal_controller,
"signals.append(\"socket_unreachable\")",
"Socket health failures no longer flag unreachable listeners",
failures,
)
require(
terminal_controller,
r"private\s+nonisolated\s+static\s+func\s+probeSocketConnectability\s*\(\s*path:\s*String\s*\)",
"Missing active socket connectability probe helper",
failures,
regex=True,
)
require(
terminal_controller,
r"connect\s*\(\s*probeSocket\s*,\s*sockaddrPtr\s*,\s*socklen_t\s*\(\s*MemoryLayout<sockaddr_un>\.size\s*\)\s*\)",
"Socket health probe no longer performs a real connect() check",
failures,
regex=True,
)
require(
terminal_controller,
"stage: \"bind_path_too_long\"",
"Socket listener start no longer reports overlong Unix socket paths",
failures,
)
require(
terminal_controller,
"Self.unixSocketPathMaxLength",
"Socket listener path-length telemetry was removed",
failures,
)
require(
app_delegate,
"private static let socketListenerHealthCheckInterval: DispatchTimeInterval = .seconds(2)",
"Socket health timer interval drifted from the fast recovery setting",
failures,
)
require(
app_delegate,
"\"socketProbePerformed\": health.socketProbePerformed ? 1 : 0",
"Health telemetry no longer records whether a connectability probe ran",
failures,
)
require(
app_delegate,
"if let socketConnectable = health.socketConnectable {",
"Health telemetry no longer gates connectability on an actual probe result",
failures,
)
require(
app_delegate,
"data[\"socketConnectable\"] = socketConnectable ? 1 : 0",
"Health telemetry no longer includes connectability when a probe ran",
failures,
)
require(
app_delegate,
"if let socketConnectErrno = health.socketConnectErrno {",
"Health telemetry no longer records connect probe errno when available",
failures,
)
return failures
def test_issue_952_socket_listener_recovery() -> None:
"""Keep the source-level recovery guards for issue #952 in CI."""
failures = collect_failures()
assert not failures, "issue #952 regression(s) detected:\n- " + "\n- ".join(failures)
def main() -> int:
"""Run the regression guard without requiring pytest to be installed."""
failures = collect_failures()
if failures:
print("FAIL: issue #952 regression(s) detected")
for failure in failures:
print(f"- {failure}")
return 1
print("PASS: issue #952 socket listener recovery guards are present")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
return last
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
start = time.time()
while time.time() - start < timeout:
if client.current_workspace() == expected:
return True
time.sleep(0.05)
return client.current_workspace() == expected
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
surfaces = client.list_surfaces()
if len(surfaces) < 2:
@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
return result
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Mark Read On Panel Focus")
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Panel Focus")
try:
client.clear_notifications()
client.reset_flash_counts()
@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult:
client.set_app_focus(False)
client.notify_surface(other[0], "focusread")
time.sleep(0.1)
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for target surface before focus")
return result
client.set_app_focus(True)
client.focus_surface(other[0])
time.sleep(0.1)
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus")
return result
items = client.list_notifications()
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None:
result.failure("Expected notification for target surface")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on focus")
elif target["is_read"]:
result.failure("Expected notification to remain unread on focus")
else:
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus dismissal")
else:
result.success("Notification marked read on focus")
result.success("Notification persisted across panel focus")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_app_active(client: cmux) -> TestResult:
result = TestResult("Mark Read On App Active")
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On App Active")
try:
client.clear_notifications()
client.set_app_focus(False)
client.notify("activate")
time.sleep(0.1)
items = client.list_notifications()
items = wait_for_notifications(client, 1)
if not items or items[0]["is_read"]:
result.failure("Expected unread notification before activation")
return result
client.simulate_app_active()
time.sleep(0.1)
items = client.list_notifications()
items = wait_for_notifications(client, 1)
if not items:
result.failure("Expected notification to remain after activation")
elif not items[0]["is_read"]:
result.failure("Expected notification to be marked read on app active")
elif items[0]["is_read"]:
result.failure("Expected notification to remain unread on app active")
else:
result.success("Notification marked read on app active")
result.success("Notification persisted across app activation")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Mark Read On Tab Switch")
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Tab Switch")
try:
client.clear_notifications()
client.set_app_focus(False)
tab1 = client.current_workspace()
client.notify("tabswitch")
time.sleep(0.1)
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for original tab before switching")
return result
tab2 = client.new_workspace()
time.sleep(0.1)
if not wait_for_current_workspace(client, tab2):
result.failure("Expected new workspace to become selected")
return result
client.set_app_focus(True)
client.select_workspace(tab1)
time.sleep(0.1)
if not wait_for_current_workspace(client, tab1):
result.failure("Expected original workspace to become selected again")
return result
items = client.list_notifications()
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None:
result.failure("Expected notification for original tab")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on tab switch")
elif target["is_read"]:
result.failure("Expected notification to remain unread on tab switch")
else:
result.success("Notification marked read on tab switch")
result.success("Notification persisted across tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
result.failure("Expected notification surface to be focused")
return result
items = client.list_notifications()
notification = next((n for n in items if n["surface_id"] == other[1]), None)
if notification is None:
result.failure("Expected notification to remain listed after notification click")
return result
if notification["is_read"]:
result.failure("Expected notification click to preserve unread state")
return result
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("Notification click focuses and flashes panel")
result.success("Notification click focuses, flashes, and preserves unread state")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -455,9 +480,9 @@ def run_tests() -> int:
results.append(test_kitty_notification_simple(client))
results.append(test_kitty_notification_chunked(client))
results.append(test_rxvt_notification_osc777(client))
results.append(test_mark_read_on_focus_change(client))
results.append(test_mark_read_on_app_active(client))
results.append(test_mark_read_on_tab_switch(client))
results.append(test_preserve_unread_on_focus_change(client))
results.append(test_preserve_unread_on_app_active(client))
results.append(test_preserve_unread_on_tab_switch(client))
results.append(test_flash_on_tab_switch(client))
results.append(test_focus_on_notification_click(client))
results.append(test_restore_focus_on_tab_switch(client))

View file

@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""
End-to-end test for split CWD inheritance.
Verifies that new split panes and new workspace tabs inherit the current
working directory from the source terminal.
Requires:
- cmux running with allowAll socket mode
- bash shell integration sourced (cmux-bash-integration.bash)
Run with a tagged instance:
CMUX_TAG=<tag> python3 tests/test_split_cwd_inheritance.py
"""
from __future__ import annotations
import os
import sys
import time
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cmux import cmux # noqa: E402
def _parse_sidebar_state(text: str) -> dict[str, str]:
data: dict[str, str] = {}
for raw in (text or "").splitlines():
line = raw.rstrip("\n")
if not line or line.startswith(" "):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
data[k.strip()] = v.strip()
return data
def _wait_for(predicate, timeout: float, interval: float, label: str):
start = time.time()
last_error: Exception | None = None
while time.time() - start < timeout:
try:
value = predicate()
if value:
return value
except Exception as e:
last_error = e
time.sleep(interval)
extra = ""
if last_error is not None:
extra = f" Last error: {last_error}"
raise AssertionError(f"Timed out waiting for {label}.{extra}")
def _wait_for_focused_cwd(
client: cmux,
expected: str,
timeout: float = 12.0,
exclude_panel: str | None = None,
) -> dict[str, str]:
"""Wait for focused_cwd to match expected.
If exclude_panel is given, also require that focused_panel differs from
that value ensuring we're checking the *new* pane, not the original.
"""
def pred():
state = _parse_sidebar_state(client.sidebar_state())
cwd = state.get("focused_cwd", "")
if cwd != expected:
return None
if exclude_panel and state.get("focused_panel", "") == exclude_panel:
return None
return state
label = f"focused_cwd={expected!r}"
if exclude_panel:
label += f" (panel != {exclude_panel})"
return _wait_for(pred, timeout=timeout, interval=0.3, label=label)
def _send_cd_and_wait(
client: cmux,
target: str,
timeout: float = 12.0,
) -> dict[str, str]:
"""cd to target and wait for sidebar focused_cwd to reflect it."""
client.send(f"cd {target}\n")
return _wait_for_focused_cwd(client, target, timeout=timeout)
def main() -> int:
tag = os.environ.get("CMUX_TAG", "")
socket_path = None
if tag:
socket_path = f"/tmp/cmux-debug-{tag}.sock"
client = cmux(socket_path=socket_path)
client.connect()
# Use resolved paths to avoid /tmp -> /private/tmp symlink mismatch on macOS
test_dir_a = str(Path("/tmp/cmux_split_cwd_test_a").resolve())
test_dir_b = str(Path("/tmp/cmux_split_cwd_test_b").resolve())
os.makedirs(test_dir_a, exist_ok=True)
os.makedirs(test_dir_b, exist_ok=True)
passed = 0
failed = 0
def check(name: str, condition: bool, detail: str = ""):
nonlocal passed, failed
if condition:
print(f" PASS {name}")
passed += 1
else:
print(f" FAIL {name}{': ' + detail if detail else ''}")
failed += 1
print("=== Split CWD Inheritance Tests ===")
# --- Setup: cd to test_dir_a in workspace 1 ---
print(" [setup] cd to test_dir_a and wait for shell integration...")
_send_cd_and_wait(client, test_dir_a)
state = _parse_sidebar_state(client.sidebar_state())
check("setup: focused_cwd is test_dir_a", state.get("focused_cwd") == test_dir_a,
f"got {state.get('focused_cwd')!r}")
# --- Test 1: New split inherits test_dir_a ---
print(" [test1] creating right split from test_dir_a...")
# Record the original panel so we can verify focus moves to the NEW pane.
original_panel = state.get("focused_panel", "")
split_result = client.new_split("right")
if not split_result:
check("split created", False)
print(f"\n{passed} passed, {failed} failed")
client.close()
return 1
check("split created", True)
# Wait for the NEW pane (different panel ID) to report test_dir_a.
time.sleep(4) # wait for new bash to start + run PROMPT_COMMAND
try:
state = _wait_for_focused_cwd(
client, test_dir_a, timeout=15.0, exclude_panel=original_panel,
)
new_panel = state.get("focused_panel", "")
check("test1: focus moved to new pane", new_panel != original_panel,
f"original={original_panel!r}, current={new_panel!r}")
check("test1: split inherited test_dir_a",
state.get("focused_cwd") == test_dir_a,
f"focused_cwd={state.get('focused_cwd')!r}")
except AssertionError:
state = _parse_sidebar_state(client.sidebar_state())
check("test1: split inherited test_dir_a", False,
f"focused_cwd={state.get('focused_cwd')!r}, focused_panel={state.get('focused_panel')!r}")
# --- Test 2: New workspace tab inherits CWD ---
# First cd to test_dir_b so we have a different dir to inherit
print(" [test2] cd to test_dir_b, then creating new workspace tab...")
_send_cd_and_wait(client, test_dir_b)
state = _parse_sidebar_state(client.sidebar_state())
original_tab = state.get("tab", "")
tab_result = client.new_tab()
if not tab_result:
check("new tab created", False)
print(f"\n{passed} passed, {failed} failed")
client.close()
return 1
check("new tab created", True)
# New workspace should be a different tab AND inherit test_dir_b
time.sleep(4)
try:
def _new_tab_with_cwd():
s = _parse_sidebar_state(client.sidebar_state())
tab_id = s.get("tab", "")
cwd = s.get("focused_cwd", "")
if tab_id != original_tab and cwd == test_dir_b:
return s
return None
state = _wait_for(
_new_tab_with_cwd, timeout=15.0, interval=0.3,
label=f"new tab with focused_cwd={test_dir_b!r}",
)
check("test2: focus moved to new tab", state.get("tab") != original_tab,
f"original={original_tab!r}, current={state.get('tab')!r}")
check("test2: new workspace inherited test_dir_b",
state.get("focused_cwd") == test_dir_b,
f"focused_cwd={state.get('focused_cwd')!r}")
except AssertionError:
state = _parse_sidebar_state(client.sidebar_state())
check("test2: new workspace inherited test_dir_b", False,
f"focused_cwd={state.get('focused_cwd')!r}, tab={state.get('tab')!r}")
print(f"\n{passed} passed, {failed} failed")
client.close()
# Cleanup
for d in [test_dir_a, test_dir_b]:
try:
os.rmdir(d)
except OSError:
pass
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -164,6 +164,12 @@ def main() -> int:
)
_must(bool(workspace), f"Expected workspace handle from identify: {identify}")
blank_opened = _run_cli_json(cli, ["browser", "open", "about:blank", "--workspace", workspace])
blank_surface = str(blank_opened.get("surface_ref") or blank_opened.get("surface_id") or "")
_must(bool(blank_surface), f"Expected about:blank browser open to return a surface: {blank_opened}")
blank_snapshot = _run_cli_text(cli, ["browser", blank_surface, "snapshot", "--interactive"])
_must("about:blank" in blank_snapshot and "get url" in blank_snapshot, f"Expected empty snapshot diagnostics for about:blank: {blank_snapshot!r}")
opened_routed = _run_cli_json(cli, ["browser", "open", page_url, "--workspace", workspace])
routed_surface = str(opened_routed.get("surface_ref") or opened_routed.get("surface_id") or "")
_must(bool(routed_surface), f"browser open --workspace returned no surface handle: {opened_routed}")

View file

@ -0,0 +1,146 @@
#!/usr/bin/env python3
"""Regression: browser wait/snapshot and screenshot CLI return usable file locations."""
import glob
import json
import os
import subprocess
import sys
import tempfile
import urllib.parse
from pathlib import Path
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: str) -> subprocess.CompletedProcess[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
def main() -> int:
cli = _find_cli_binary()
with cmux(SOCKET_PATH) as c:
opened = c._call("browser.open_split", {"url": "about:blank"}) or {}
target = str(opened.get("surface_id") or opened.get("surface_ref") or "")
_must(target != "", f"browser.open_split returned no surface handle: {opened}")
html = """
<!doctype html>
<html>
<head><title>cmux-browser-cli-regression</title></head>
<body>
<main>
<h1>browser cli regression</h1>
<p id="status">ready</p>
</main>
</body>
</html>
""".strip()
data_url = "data:text/html;charset=utf-8," + urllib.parse.quote(html)
c._call("browser.navigate", {"surface_id": target, "url": data_url})
wait_proc = _run_cli(
cli,
"browser",
target,
"wait",
"--load-state",
"interactive",
"--timeout-ms",
"5000",
)
_must(wait_proc.stdout.strip() == "OK", f"Expected browser wait OK output: {wait_proc.stdout!r}")
snapshot_payload = c._call("browser.snapshot", {"surface_id": target}) or {}
refs = snapshot_payload.get("refs") or {}
_must(isinstance(refs, dict) and len(refs) > 0, f"Expected snapshot refs for ref-based wait coverage: {snapshot_payload}")
ref_selector = str(next(iter(refs.keys())))
ref_wait_proc = _run_cli(
cli,
"browser",
target,
"wait",
"--selector",
ref_selector,
"--timeout-ms",
"2000",
)
_must(ref_wait_proc.stdout.strip() == "OK", f"Expected browser wait to resolve snapshot refs: {ref_wait_proc.stdout!r}")
snapshot_proc = _run_cli(cli, "browser", target, "snapshot", "--compact")
_must(
snapshot_proc.stdout.strip().startswith("- document"),
f"Expected snapshot command to succeed with structured output: {snapshot_proc.stdout!r}",
)
screenshot_json_proc = _run_cli(cli, "browser", target, "screenshot", "--json")
screenshot_json_text = screenshot_json_proc.stdout.strip()
payload = json.loads(screenshot_json_text or "{}")
_must("\\/" not in screenshot_json_text, f"Expected screenshot JSON without escaped slashes: {screenshot_json_text!r}")
_must("png_base64" not in payload, f"Expected screenshot JSON to omit png_base64 when file location is available: {payload}")
screenshot_path = str(payload.get("path") or "")
screenshot_url = str(payload.get("url") or "")
_must(screenshot_path.startswith("/"), f"Expected screenshot path in JSON payload: {payload}")
_must(screenshot_url.startswith("file://"), f"Expected screenshot file URL in JSON payload: {payload}")
_must(Path(screenshot_path).is_file(), f"Expected screenshot file to exist: {payload}")
out_dir = Path(tempfile.mkdtemp(prefix="cmux-browser-screenshot-cli-")) / "nested" / "dir"
out_path = out_dir / "capture.png"
screenshot_out_proc = _run_cli(
cli,
"browser",
target,
"screenshot",
"--out",
str(out_path),
)
_must(screenshot_out_proc.stdout.strip() == f"OK {out_path}", f"Expected --out to print the requested path: {screenshot_out_proc.stdout!r}")
_must("file://" not in screenshot_out_proc.stdout, f"Expected --out to print a path, not a file URL: {screenshot_out_proc.stdout!r}")
_must(out_path.is_file(), f"Expected --out screenshot file to exist: {out_path}")
print("PASS: browser CLI wait/snapshot and screenshot output work end-to-end")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Regression checks for cmux-browser get selector examples."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmuxError
ROOT = Path(__file__).resolve().parents[1]
COMMANDS = ROOT / "skills/cmux-browser/references/commands.md"
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def main() -> int:
commands = COMMANDS.read_text(encoding="utf-8")
_must("`agent-browser get text <ref>` -> `cmux browser <surface> get text <ref-or-selector>`" in commands, "Expected get text mapping to mention selector support")
_must("cmux browser <surface> get text body" in commands, "Expected get text body example")
_must("cmux browser <surface> get html body" in commands, "Expected get html body example")
_must('cmux browser <surface> get value "#email"' in commands, "Expected get value selector example")
_must('cmux browser <surface> get attr "#email" --attr placeholder' in commands, "Expected get attr selector example")
_must("cmux browser <surface> get text|html|value|attr|count|box|styles ..." not in commands, "Unexpected bare get example block")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""Regression checks for cmux-browser js_error troubleshooting docs."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from cmux import cmuxError
ROOT = Path(__file__).resolve().parents[1]
SKILL = ROOT / "skills/cmux-browser/SKILL.md"
def _must(cond: bool, msg: str) -> None:
if not cond:
raise cmuxError(msg)
def main() -> int:
skill = SKILL.read_text(encoding="utf-8")
_must("## Troubleshooting" in skill, "Expected Troubleshooting section in cmux-browser skill")
_must("### `js_error` on `snapshot --interactive` or `eval`" in skill, "Expected js_error troubleshooting heading")
_must("cmux browser surface:7 get url" in skill, "Expected get url recovery step")
_must("cmux browser surface:7 get text body" in skill, "Expected get text body recovery step")
_must("cmux browser surface:7 get html body" in skill, "Expected get html body recovery step")
_must("when `snapshot --interactive` or `eval` returns `js_error`" in skill, "Expected js_error fallback guidance")
_must("navigate to a simpler intermediate page" in skill, "Expected simpler-page fallback guidance")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
E2E: focusing a panel clears its notification and triggers a flash.
E2E: focusing a panel preserves its notification and triggers a flash.
Note: This uses the socket focus command (no assistive access needed).
"""
@ -74,8 +74,12 @@ def main() -> int:
client.send("x")
time.sleep(0.2)
if not wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification did not become read after focus")
if wait_for_notification(client, surface_id, is_read=True, timeout=2.0):
print("FAIL: Notification became read after focus")
return 1
items = client.list_notifications()
if not any(item["surface_id"] == surface_id and not item["is_read"] for item in items):
print("FAIL: Notification did not remain present and unread after focus")
return 1
final_flash = client.flash_count(term_b)
@ -93,7 +97,7 @@ def main() -> int:
except Exception:
pass
print("PASS: Focus clears notification and flashes panel")
print("PASS: Focus preserves notification and flashes panel")
return 0
except (cmuxError, RuntimeError) as exc:
print(f"FAIL: {exc}")

View file

@ -58,6 +58,15 @@ def wait_for_flash_count(client: cmux, surface: str, minimum: int = 1, timeout:
return last
def wait_for_current_workspace(client: cmux, expected: str, timeout: float = 2.0) -> bool:
start = time.time()
while time.time() - start < timeout:
if client.current_workspace() == expected:
return True
time.sleep(0.05)
return client.current_workspace() == expected
def ensure_two_surfaces(client: cmux) -> list[tuple[int, str, bool]]:
surfaces = client.list_surfaces()
if len(surfaces) < 2:
@ -215,8 +224,8 @@ def test_rxvt_notification_osc777(client: cmux) -> TestResult:
return result
def test_mark_read_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Mark Read On Panel Focus")
def test_preserve_unread_on_focus_change(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Panel Focus")
try:
client.clear_notifications()
client.reset_flash_counts()
@ -229,81 +238,88 @@ def test_mark_read_on_focus_change(client: cmux) -> TestResult:
client.set_app_focus(False)
client.notify_surface(other[0], "focusread")
time.sleep(0.1)
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for target surface before focus")
return result
client.set_app_focus(True)
client.focus_surface(other[0])
time.sleep(0.1)
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus")
return result
items = client.list_notifications()
target = next((n for n in items if n["surface_id"] == other[1]), None)
if target is None:
result.failure("Expected notification for target surface")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on focus")
elif target["is_read"]:
result.failure("Expected notification to remain unread on focus")
else:
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure("Expected flash on panel focus dismissal")
else:
result.success("Notification marked read on focus")
result.success("Notification persisted across panel focus")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_app_active(client: cmux) -> TestResult:
result = TestResult("Mark Read On App Active")
def test_preserve_unread_on_app_active(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On App Active")
try:
client.clear_notifications()
client.set_app_focus(False)
client.notify("activate")
time.sleep(0.1)
items = client.list_notifications()
items = wait_for_notifications(client, 1)
if not items or items[0]["is_read"]:
result.failure("Expected unread notification before activation")
return result
client.simulate_app_active()
time.sleep(0.1)
items = client.list_notifications()
items = wait_for_notifications(client, 1)
if not items:
result.failure("Expected notification to remain after activation")
elif not items[0]["is_read"]:
result.failure("Expected notification to be marked read on app active")
elif items[0]["is_read"]:
result.failure("Expected notification to remain unread on app active")
else:
result.success("Notification marked read on app active")
result.success("Notification persisted across app activation")
except Exception as e:
result.failure(f"Exception: {e}")
return result
def test_mark_read_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Mark Read On Tab Switch")
def test_preserve_unread_on_tab_switch(client: cmux) -> TestResult:
result = TestResult("Preserve Unread On Tab Switch")
try:
client.clear_notifications()
client.set_app_focus(False)
tab1 = client.current_workspace()
client.notify("tabswitch")
time.sleep(0.1)
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None or target["is_read"]:
result.failure("Expected unread notification for original tab before switching")
return result
tab2 = client.new_workspace()
time.sleep(0.1)
if not wait_for_current_workspace(client, tab2):
result.failure("Expected new workspace to become selected")
return result
client.set_app_focus(True)
client.select_workspace(tab1)
time.sleep(0.1)
if not wait_for_current_workspace(client, tab1):
result.failure("Expected original workspace to become selected again")
return result
items = client.list_notifications()
items = wait_for_notifications(client, 1)
target = next((n for n in items if n["workspace_id"] == tab1), None)
if target is None:
result.failure("Expected notification for original tab")
elif not target["is_read"]:
result.failure("Expected notification to be marked read on tab switch")
elif target["is_read"]:
result.failure("Expected notification to remain unread on tab switch")
else:
result.success("Notification marked read on tab switch")
result.success("Notification persisted across tab switch")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -371,11 +387,20 @@ def test_focus_on_notification_click(client: cmux) -> TestResult:
result.failure("Expected notification surface to be focused")
return result
items = client.list_notifications()
notification = next((n for n in items if n["surface_id"] == other[1]), None)
if notification is None:
result.failure("Expected notification to remain listed after notification click")
return result
if notification["is_read"]:
result.failure("Expected notification click to preserve unread state")
return result
count = wait_for_flash_count(client, other[1], minimum=1, timeout=2.0)
if count < 1:
result.failure(f"Expected flash count >= 1, got {count}")
else:
result.success("Notification click focuses and flashes panel")
result.success("Notification click focuses, flashes, and preserves unread state")
except Exception as e:
result.failure(f"Exception: {e}")
return result
@ -455,9 +480,9 @@ def run_tests() -> int:
results.append(test_kitty_notification_simple(client))
results.append(test_kitty_notification_chunked(client))
results.append(test_rxvt_notification_osc777(client))
results.append(test_mark_read_on_focus_change(client))
results.append(test_mark_read_on_app_active(client))
results.append(test_mark_read_on_tab_switch(client))
results.append(test_preserve_unread_on_focus_change(client))
results.append(test_preserve_unread_on_app_active(client))
results.append(test_preserve_unread_on_tab_switch(client))
results.append(test_flash_on_tab_switch(client))
results.append(test_focus_on_notification_click(client))
results.append(test_restore_focus_on_tab_switch(client))